squirrelphp/entities

View on GitHub
src/MultiRepositoryReadOnly.php

Summary

Maintainability
F
1 wk
Test Coverage
A
100%
<?php

namespace Squirrel\Entities;

use Squirrel\Debug\Debug;
use Squirrel\Queries\Builder\BuilderInterface;
use Squirrel\Queries\DBException;
use Squirrel\Queries\DBInterface;
use Squirrel\Queries\Exception\DBInvalidOptionException;
use Squirrel\Types\Coerce;
use Squirrel\Types\Coerceable;

/**
 * If more than one table needs to be selected at once this class
 * combines the knowledge of multiple Repository classes to create
 * a query which is simple and secure
 */
class MultiRepositoryReadOnly implements MultiRepositoryReadOnlyInterface
{
    protected DBInterface $db;

    public function count(array $query): int
    {
        $sanitizedQuery = [
            'fields' => [
                'num' => 'COUNT(*)',
            ],
        ];

        $sanitizedQuery['repositories'] = $query['repositories'];

        if (isset($query['tables'])) {
            $sanitizedQuery['tables'] = $query['tables'];
        }

        if (isset($query['where'])) {
            $sanitizedQuery['where'] = $query['where'];
        }

        if (isset($query['lock'])) {
            $sanitizedQuery['lock'] = $query['lock'];
        }

        // Use our internal functions to not repeat ourselves
        $result = $this->fetchOne($sanitizedQuery);

        return $result['num'] ?? 0;
    }

    public function select(array $query): MultiRepositorySelectQueryInterface
    {
        // Freeform query was detected
        if (isset($query['query']) || isset($query['parameters'])) {
            [$sqlQuery, $parameters, $selectTypes, $selectTypesNullable] = $this->buildSelectQueryFreeform($query);
        } else { // Structured query
            [$sqlQuery, $selectTypes, $selectTypesNullable] = $this->buildSelectQueryStructured($query);
        }

        // Get all the data from the database
        try {
            return new MultiRepositorySelectQuery(
                $this->db->select($sqlQuery, $parameters ?? []),
                $selectTypes,
                $selectTypesNullable,
            );
        } catch (DBException $e) {
            throw Debug::createException(
                \get_class($e),
                $e->getMessage(),
                ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                previousException: $e->getPrevious(),
            );
        }
    }

    public function fetch(MultiRepositorySelectQueryInterface $selectQuery): ?array
    {
        try {
            $result = $this->db->fetch($selectQuery->getQuery());
        } catch (DBException $e) {
            throw Debug::createException(
                \get_class($e),
                $e->getMessage(),
                ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                previousException: $e->getPrevious(),
            );
        }

        if ($result === null) {
            return null;
        }

        return $this->processSelectResult($result, $selectQuery->getTypes(), $selectQuery->getTypesNullable());
    }

    public function clear(MultiRepositorySelectQueryInterface $selectQuery): void
    {
        try {
            $this->db->clear($selectQuery->getQuery());
        } catch (DBException $e) {
            throw Debug::createException(
                \get_class($e),
                $e->getMessage(),
                ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                previousException: $e->getPrevious(),
            );
        }
    }

    public function fetchOne(array $query): ?array
    {
        if (isset($query['limit']) && $query['limit'] !== 1) {
            throw Debug::createException(
                DBInvalidOptionException::class,
                'Row limit cannot be set for fetchOne query: ' . Debug::sanitizeData($query),
                ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
            );
        }

        // Use our internal functions to not repeat ourselves
        $selectQuery = $this->select($query);
        $result = $this->fetch($selectQuery);
        $this->clear($selectQuery);

        // Return the result object
        return $result;
    }

    public function fetchAll(array $query): array
    {
        // Freeform query was detected
        if (isset($query['query']) || isset($query['parameters'])) {
            [$sqlQuery, $parameters, $selectTypes, $selectTypesNullable] = $this->buildSelectQueryFreeform($query);
        } else { // Structured query
            [$sqlQuery, $selectTypes, $selectTypesNullable] = $this->buildSelectQueryStructured($query);
        }

        // Get all the data from the database
        try {
            $tableResults = $this->db->fetchAll($sqlQuery, $parameters ?? []);
        } catch (DBException $e) {
            throw Debug::createException(
                \get_class($e),
                $e->getMessage(),
                ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                previousException: $e->getPrevious(),
            );
        }

        // Process the select results
        return $this->processSelectResults($tableResults, $selectTypes, $selectTypesNullable);
    }

    public function fetchAllAndFlatten(array $query): array
    {
        $processedResults = $this->fetchAll($query);

        $list = [];

        // Go through table results
        foreach ($processedResults as $objIndex => $objEntry) {
            // Go through all table fields
            foreach ($objEntry as $fieldName => $fieldValue) {
                $list[] = $fieldValue;
            }
        }

        return $list;
    }

    /**
     * Process options and make sure all values are valid
     *
     * @param array $validOptions List of valid options and default values for them
     * @param array $options List of provided options which need to be processed
     * @param bool $writing Whether this is a writing operation or not
     */
    protected function processOptions(array $validOptions, array $options, bool $writing = false): array
    {
        // Reset DB class - needs to be set by the current options
        $dbInstance = null;

        // Copy over the default valid options as a starting point for our options
        $sanitizedOptions = $validOptions;

        // Go through the defined options
        foreach ($options as $optKey => $optVal) {
            // Defined option is not in the list of valid options
            if (!isset($validOptions[$optKey])) {
                throw Debug::createException(
                    DBInvalidOptionException::class,
                    'Unknown option key ' . Debug::sanitizeData($optKey),
                    ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                );
            }

            // Make sure the variable type for the defined option is valid
            switch ($optKey) {
                // These are checked & converted by SQL component
                case 'limit':
                case 'offset':
                case 'lock':
                    break;
                // Already type hinted "query" as string
                case 'query':
                    break;
                default:
                    if (!\is_array($optVal)) {
                        throw Debug::createException(
                            DBInvalidOptionException::class,
                            'Option key ' . Debug::sanitizeData($optKey) .
                            ' had a non-array value: ' . Debug::sanitizeData($optVal),
                            ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                        );
                    }
                    break;
            }

            $sanitizedOptions[$optKey] = $optVal;
        }

        // Make sure tables array was defined
        if (!isset($sanitizedOptions['repositories']) || \count($sanitizedOptions['repositories']) === 0) {
            throw Debug::createException(
                DBInvalidOptionException::class,
                'No repositories specified',
                ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
            );
        }

        // No table joins defined - just join them by "default" via repositories definition
        if (isset($validOptions['tables']) && \count($sanitizedOptions['tables']) === 0) {
            $sanitizedOptions['tables'] = \array_keys($sanitizedOptions['repositories']);
        }

        // WHERE needs some restrictions to glue the tables together - except if there is only one repository
        if (
            isset($validOptions['where'])
            && \count($sanitizedOptions['where']) === 0
            && \count($sanitizedOptions['repositories']) > 1
        ) {
            throw Debug::createException(
                DBInvalidOptionException::class,
                'No "where" definitions',
                ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
            );
        }

        // SELECT fields need to be defined
        if (isset($validOptions['fields']) && \count($sanitizedOptions['fields']) === 0) {
            throw Debug::createException(
                DBInvalidOptionException::class,
                'No "fields" definition',
                ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
            );
        }

        // Query in freeform selects and updates needs to not be empty
        if (isset($validOptions['query']) && \strlen($sanitizedOptions['query']) === 0) {
            throw Debug::createException(
                DBInvalidOptionException::class,
                'No "query" definition',
                ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
            );
        }

        // Make sure parameters for a freestyle query are valid
        if (isset($validOptions['parameters']) && \count($sanitizedOptions['parameters']) > 0) {
            // Remove keys from parameters - they are not needed
            $sanitizedOptions['parameters'] = \array_values($sanitizedOptions['parameters']);

            // Check all provided parameters
            foreach ($sanitizedOptions['parameters'] as $key => $value) {
                // Only scalar values are allowed
                if (!\is_scalar($value)) {
                    throw Debug::createException(
                        DBInvalidOptionException::class,
                        'Non-scalar "parameters" definition',
                        ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                    );
                }

                // Convert bool to int
                if (\is_bool($value)) {
                    $value = \intval($value);
                }

                $sanitizedOptions['parameters'][$key] = $value;
            }
        }

        /**
         * Name of the tables for this query
         *
         * @var array
         */
        $tableName = [];

        /**
         * Conversion from object to table fields
         *
         * @var array
         */
        $objectToTableFields = [];

        /**
         * Types of the variables in the object for type casting
         *
         * @var array
         */
        $objectTypes = [];

        /**
         * Whether variables can be NULL or not
         *
         * @var array
         */
        $objectTypesNullable = [];

        // Go through tables to prepare the repositories
        foreach ($sanitizedOptions['repositories'] as $name => $class) {
            // Make sure every entry in the tables array is valid
            if (!\is_string($name) || \str_contains($name, '.')) {
                throw Debug::createException(
                    DBInvalidOptionException::class,
                    'Invalid "repositories" key definition: ' . Debug::sanitizeData($name),
                    ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                );
            } elseif ($class instanceof RepositoryBuilderReadOnlyInterface) {
                // Make sure the repository is writeable if we are doing a writing query
                if ($writing === true && !($class instanceof RepositoryBuilderWriteableInterface)) {
                    throw Debug::createException(
                        DBInvalidOptionException::class,
                        'Non-writeable "repositories" object definition for writing operation',
                        ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                    );
                }

                try {
                    // Dive into the repository builder class and get the raw repository behind it
                    $builderRepositoryReflection = new \ReflectionClass($class);
                    $builderRepositoryPropertyReflection = $builderRepositoryReflection->getProperty('repository');
                    $builderRepositoryPropertyReflection->setAccessible(true);
                    $baseRepository = $builderRepositoryPropertyReflection->getValue($class);

                    // Get configuration from within the base repository
                    $baseRepositoryReflection = new \ReflectionClass($baseRepository);
                    $baseRepositoryPropertyReflection = $baseRepositoryReflection->getProperty('config');
                    $baseRepositoryPropertyReflection->setAccessible(true);
                    $class = $baseRepositoryPropertyReflection->getValue($baseRepository);

                    // Get DBInterface from base repository
                    $baseRepositoryPropertyReflection = $baseRepositoryReflection->getProperty('db');
                    $baseRepositoryPropertyReflection->setAccessible(true);
                    $dbClass = $baseRepositoryPropertyReflection->getValue($baseRepository);

                    // Make sure all DBInterface instances are the same = the same connection is used
                    if (isset($dbInstance) && $dbClass !== $dbInstance) {
                        throw Debug::createException(
                            DBInvalidOptionException::class,
                            'Repositories have different database connections, combined query is not possible',
                            ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                        );
                    }

                    $dbInstance = $dbClass;
                } catch (\ReflectionException $e) {
                    throw Debug::createException(
                        DBInvalidOptionException::class,
                        'Repository configuration could not be retrieved through reflection, ' .
                        'repository class not as expected: ' . $e->getMessage(),
                        ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                    );
                }
            } elseif ($class instanceof RepositoryReadOnlyInterface) {
                // Make sure the repository is writeable if we are doing a writing query
                if ($writing === true && !($class instanceof RepositoryWriteableInterface)) {
                    throw Debug::createException(
                        DBInvalidOptionException::class,
                        'Non-writeable "repositories" object definition for writing operation',
                        ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                    );
                }

                try {
                    $baseRepositoryReflection = new \ReflectionClass($class);

                    // Get DBInterface from base repository
                    $baseRepositoryPropertyReflection = $baseRepositoryReflection->getProperty('db');
                    $baseRepositoryPropertyReflection->setAccessible(true);
                    $dbClass = $baseRepositoryPropertyReflection->getValue($class);

                    // Get configuration from within the base repository
                    $baseRepositoryPropertyReflection = $baseRepositoryReflection->getProperty('config');
                    $baseRepositoryPropertyReflection->setAccessible(true);
                    $class = $baseRepositoryPropertyReflection->getValue($class);

                    // Make sure all DBInterface instances are the same = the same connection is used
                    if (isset($dbInstance) && $dbClass !== $dbInstance) {
                        throw Debug::createException(
                            DBInvalidOptionException::class,
                            'Repositories have different database connections, combined query is not possible',
                            ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                        );
                    }

                    $dbInstance = $dbClass;
                } catch (\ReflectionException $e) {
                    throw Debug::createException(
                        DBInvalidOptionException::class,
                        'Repository configuration could not be retrieved through reflection, ' .
                        'repository class not as expected: ' . $e->getMessage(),
                        ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                    );
                }
            } else {
                throw Debug::createException(
                    DBInvalidOptionException::class,
                    'Invalid repository specified, does not implement ' .
                    'RepositoryReadOnlyInterface or RepositoryBuilderReadOnlyInterface: ' .
                    Debug::sanitizeData($sanitizedOptions['repositories']),
                    ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                );
            }

            // Name of the table
            $tableName[$name] = $class->getTableName();

            // Conversion from object to table fields
            $objectToTableFields[$name] = $class->getObjectToTableFields();

            // Types of the variables in the object for type casting
            $objectTypes[$name] = $class->getObjectTypes();

            // If a variable can be NULL or not
            $objectTypesNullable[$name] = $class->getObjectTypesNullable();
        }

        if ($dbInstance instanceof DBInterface) {
            $this->db = $dbInstance;
        } else {
            throw Debug::createException(
                DBInvalidOptionException::class,
                'Repositories did not contain a valid database connection' .
                Debug::sanitizeData($sanitizedOptions['repositories']),
                ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
            );
        }

        // Remove repositories data - not needed for query to DBInterface
        unset($sanitizedOptions['repositories']);

        // Return all processed options and object-to-table information
        return [$sanitizedOptions, $tableName, $objectToTableFields, $objectTypes, $objectTypesNullable];
    }

    private function buildSelectQueryStructured(array $query): array
    {
        // Process options and make sure all values are valid
        [
            $sanitizedOptions,
            $tableName,
            $objectToTableFields,
            $objectTypes,
            $objectTypesNullable,
        ] = $this->processOptions([
            'repositories' => [],
            'fields' => [],
            'tables' => [],
            'where' => [],
            'group' => [],
            'order' => [],
            'limit' => 0,
            'offset' => 0,
            'lock' => false,
        ], $query);

        // Build SELECT part of the query
        [$sanitizedOptions['fields'], $selectTypes, $selectTypesNullable] = $this->buildFieldSelection(
            $sanitizedOptions['fields'],
            $objectToTableFields,
            $objectTypes,
            $objectTypesNullable,
        );

        // List of finished FROM expressions, to be imploded with , + possible query values
        $sanitizedOptions['tables'] = $this->preprocessJoins(
            $sanitizedOptions['tables'],
            $tableName,
            $objectToTableFields,
        );

        // List of finished WHERE expressions, to be imploded with ANDs
        $sanitizedOptions['where'] = $this->preprocessWhere($sanitizedOptions['where'], $objectToTableFields);

        // GROUP BY was defined
        if (isset($sanitizedOptions['group']) && \count($sanitizedOptions['group']) > 0) {
            $sanitizedOptions['group'] = $this->preprocessGroup($sanitizedOptions['group'], $objectToTableFields);
        } else {
            unset($sanitizedOptions['group']);
        }

        // Order was defined
        if (isset($sanitizedOptions['order']) && \count($sanitizedOptions['order']) > 0) {
            $sanitizedOptions['order'] = $this->preprocessOrder($sanitizedOptions['order'], $objectToTableFields);
        } else {
            unset($sanitizedOptions['order']);
        }

        // No limit - remove it from options
        if ($sanitizedOptions['limit'] === 0) {
            unset($sanitizedOptions['limit']);
        }

        // No offset - remove it from options
        if ($sanitizedOptions['offset'] === 0) {
            unset($sanitizedOptions['offset']);
        }

        // No lock - remove it from options
        if ($sanitizedOptions['lock'] === false) {
            unset($sanitizedOptions['lock']);
        }

        return [$sanitizedOptions, $selectTypes, $selectTypesNullable];
    }

    private function buildSelectQueryFreeform(array $options): array
    {
        // Process options and make sure all values are valid
        [
            $sanitizedOptions,
            $tableName,
            $objectToTableFields,
            $objectTypes,
            $objectTypesNullable,
        ] = $this->processOptions([
            'repositories' => [],
            'fields' => [],
            'query' => '',
            'parameters' => [],
        ], $options);

        // Process the query
        $sqlQuery = $this->buildFreeform($sanitizedOptions['query'], $tableName, $objectToTableFields);

        // Build select part of the query
        [$selectProcessed, $selectTypes, $selectTypesNullable] = $this->buildFieldSelection(
            $sanitizedOptions['fields'],
            $objectToTableFields,
            $objectTypes,
            $objectTypesNullable,
            true,
        );

        return [
            'SELECT ' . \implode(',', $selectProcessed) . ' FROM ' . $sqlQuery,
            $sanitizedOptions['parameters'],
            $selectTypes,
            $selectTypesNullable,
        ];
    }

    /**
     * Build freeform query by replacing object names and object field names with the
     * actual table names and table field names
     */
    protected function buildFreeform(string $query, array $tableName, array $objectToTableFields): string
    {
        // Replace all expressions of all involved repositories
        foreach ($objectToTableFields as $table => $tableFields) {
            // Replace table name placeholders
            $query = \str_replace(
                ':' . $table . ':',
                $this->db->quoteIdentifier($tableName[$table]) . ' ' . $this->db->quoteIdentifier($table),
                $query,
                $count,
            );

            // Replace all table fields with correct values
            foreach ($tableFields as $objFieldName => $sqlFieldName) {
                $query = \str_replace(
                    ':' . $table . '.' . $objFieldName . ':',
                    $this->db->quoteIdentifier($table . '.' . $sqlFieldName),
                    $query,
                    $count,
                );
            }
        }

        // If we still have unresolved expressions, something went wrong
        if (\str_contains($query, ':')) {
            throw Debug::createException(
                DBInvalidOptionException::class,
                'Invalid "query" definition, unresolved colons remain',
                ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
            );
        }

        // Return processed SQL query
        return $query;
    }

    /**
     * Build SELECT part of the query
     */
    private function buildFieldSelection(
        array $selectOptions,
        array $objectToTableFields,
        array $objectTypes,
        array $objectTypesNullable,
        bool $generateSql = false,
    ): array {
        // Calculated select fields
        $selectProcessed = [];
        $selectTypes = [];
        $selectTypesNullable = [];

        // Go through all the select fields
        foreach ($selectOptions as $name => $field) {
            // No custom name for the field
            if (\is_int($name)) {
                $name = $field;
            }

            // Name always has to be a string
            if (!\is_string($name)) {
                throw Debug::createException(
                    DBInvalidOptionException::class,
                    'Invalid "fields" definition, key is not a string: ' . Debug::sanitizeData($name),
                    ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                );
            }

            // Field always has to be a string
            if (!\is_string($field)) {
                throw Debug::createException(
                    DBInvalidOptionException::class,
                    'Invalid "fields" definition, value for ' .
                    Debug::sanitizeData($name) . ' is not a string: ' . Debug::sanitizeData($field),
                    ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                );
            }

            // No expressions allowed in name part!
            if (\str_contains($name, ':')) {
                throw Debug::createException(
                    DBInvalidOptionException::class,
                    'Invalid "fields" definition, name ' .
                    Debug::sanitizeData($name) . ' contains a colon',
                    ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                );
            }

            // Special case of COUNT(*) - unlike any other SQL expression, and it should work
            if (\strtoupper($field) === 'COUNT(*)') {
                $selectProcessed[] = $field . ' AS ' . '"' . $name . '"';
                $selectTypes[$name] = 'int';
            } elseif (!\str_contains($field, ':')) { // No expression in field part
                // Get separated table and field parts
                $fieldParts = \explode('.', $field);

                // Field does not exist in this way
                if (!isset($fieldParts[1]) || !isset($objectToTableFields[$fieldParts[0]][$fieldParts[1]])) {
                    throw Debug::createException(
                        DBInvalidOptionException::class,
                        'Invalid "fields" definition, unknown field name: ' .
                        Debug::sanitizeData($field),
                        ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                    );
                }

                // We map the SQL field to the full object field (table.field)
                if ($generateSql === true) {
                    $selectProcessed[] = $this->db->quoteIdentifier(
                        $fieldParts[0] . '.' . $objectToTableFields[$fieldParts[0]][$fieldParts[1]],
                    ) . ' AS "' . $name . '"';
                } else {
                    $selectProcessed[$name] = $fieldParts[0] . '.' .
                        $objectToTableFields[$fieldParts[0]][$fieldParts[1]];
                }
                $selectTypes[$name] = $objectTypes[$fieldParts[0]][$fieldParts[1]];
                $selectTypesNullable[$name] = $objectTypesNullable[$fieldParts[0]][$fieldParts[1]];
            } else { // Expressions in field part
                // The type guessed by the used table fields
                $type = '';
                $nullable = false;

                // Replace all expressions of all involved repositories
                foreach ($objectToTableFields as $table => $tableFields) {
                    foreach ($tableFields as $objFieldName => $sqlFieldName) {
                        $field = \str_replace(
                            ':' . $table . '.' . $objFieldName . ':',
                            $this->db->quoteIdentifier($table . '.' . $sqlFieldName),
                            $field,
                            $count,
                        );

                        // Replacement occured, so this field name is used
                        if ($count > 0) {
                            // The u in front of the types stands for "uncertain", meaning we still have to test
                            // the values later if they conform to the assumed type

                            // We narrow the type to bool if only bool values are used
                            if ($objectTypes[$table][$objFieldName] === 'bool' && $type === '') {
                                $type = 'ubool';
                            } elseif (
                                $objectTypes[$table][$objFieldName] === 'int' &&
                                ($type === '' || $type === 'ubool')
                            ) { // We narrow the type to int if only int and bool values are used
                                $type = 'uint';
                            } elseif ($objectTypes[$table][$objFieldName] === 'float' && $type !== 'string') {
                                // If any float values are used, we use float type if there are no strings
                                $type = 'ufloat';
                            } elseif ($objectTypes[$table][$objFieldName] === 'string') {
                                // As soon as a string type is used we always use string type
                                $type = 'string';
                            }

                            // NULL is a possible value for this field
                            if ($objectTypesNullable[$table][$objFieldName] === true) {
                                $nullable = true;
                            }
                        }
                    }
                }

                // If we still have unresolved expressions, something went wrong
                if (\str_contains($field, ':')) {
                    throw Debug::createException(
                        DBInvalidOptionException::class,
                        'Invalid "fields" definition, unresolved colons: ' .
                        Debug::sanitizeData($field),
                        ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                    );
                }

                // We guess the type is string if we have a CONCAT or REPLACE in the string
                if (\str_contains($field, 'CONCAT') || \str_contains($field, 'REPLACE')) {
                    $type = 'string';
                }

                // If no type could be guessed, we assume string
                if ($type === '') {
                    $type = 'string';
                }

                // Assign the select expression
                $selectProcessed[] = '(' . $field . ')' . ' AS ' . '"' . $name . '"';
                $selectTypes[$name] = $type;
                $selectTypesNullable[$name] = $nullable;
            }
        }

        return [$selectProcessed, $selectTypes, $selectTypesNullable];
    }

    /**
     * Process the results retrieved from a SELECT query
     */
    private function processSelectResults(
        array $tableObjects,
        array $selectTypes,
        array $selectTypesNullable,
    ): array {
        // Go through result set
        foreach ($tableObjects as $entryCount => $entry) {
            $tableObjects[$entryCount] = $this->processSelectResult($entry, $selectTypes, $selectTypesNullable);
        }

        return $tableObjects;
    }

    private function processSelectResult(
        array $entry,
        array $selectTypes,
        array $selectTypesNullable,
    ): array {
        foreach ($entry as $key => $value) {
            // Special case of nullable types
            if (\is_null($value) && $selectTypesNullable[$key] === true) {
                $entry[$key] = null;
                continue;
            }

            // Test boolean and numeric values to make sure we do not lose information
            if ($selectTypes[$key] === 'ubool') {
                // Only accept 0 and 1 - all other values are not necessarily boolean
                if ($value === '0' || $value === '1') {
                    $selectTypes[$key] = 'bool';
                } else {
                    $selectTypes[$key] = 'string';
                }
            } elseif ($selectTypes[$key] === 'uint' || $selectTypes[$key] === 'ufloat') {
                // Non-coerceable values are kept as string
                if (!Coerceable::toFloat($value)) {
                    $selectTypes[$key] = 'string';
                } elseif (
                    // Numeric values where we lose some information when converting to integer are kept as a string
                    $selectTypes[$key] === 'uint'
                    && !Coerceable::toInt($value)
                ) {
                    $selectTypes[$key] = 'string';
                } else { // No information loss detected, use int or float type
                    $selectTypes[$key] = \substr($selectTypes[$key], 1);
                }
            }

            try {
                $entry[$key] = match ($selectTypes[$key]) {
                    'int' => Coerce::toInt($value),
                    'bool' => Coerce::toBool($value),
                    'float' => Coerce::toFloat($value),
                    'string' => Coerce::toString($value),
                    default => throw Debug::createException(
                        DBInvalidOptionException::class,
                        'Unknown casting "' . $selectTypes[$key] . '" for object variable ' . Debug::sanitizeData($key),
                        ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                    ),
                };
            } catch (\TypeError $e) {
                \trigger_error('Wrong type for ' . $key . ': ' . $e->getMessage(), E_USER_DEPRECATED);

                $entry[$key] = match ($selectTypes[$key]) {
                    'int' => \intval($value),
                    'bool' => \boolval($value),
                    'float' => \floatval($value),
                    'string' => \strval($value),
                };
            }
        }

        return $entry;
    }

    /**
     * Prepare the joins between tables part for the SQL component
     */
    protected function preprocessJoins(array $tables, array $tableNames, array $objectToTableFields): array
    {
        // List of table selection, needs to be imploded with a comma for SQL query
        $tablesProcessed = [];

        // Go through table selection
        foreach ($tables as $expression => $values) {
            // No values, only an expression
            if (\is_int($expression)) {
                $expression = $values;
                $values = null;
            }

            // Expression always has to be a string
            if (!\is_string($expression)) {
                throw Debug::createException(
                    DBInvalidOptionException::class,
                    'Invalid "tables" / table join definition, expression is not a string: ' .
                    Debug::sanitizeData($expression),
                    ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                );
            }

            // No expression, only a table name
            if (!\str_contains($expression, ':')) {
                // Make sure the table alias exists
                if (!isset($tableNames[$expression])) {
                    throw Debug::createException(
                        DBInvalidOptionException::class,
                        'Invalid "tables" / table join definition, alias not found: ' .
                        Debug::sanitizeData($expression),
                        ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                    );
                }

                // Quoting not necessary, will be handled by SQL component
                $tablesProcessed[] = $tableNames[$expression] . ' ' . $expression;
            } else { // An expression with : variables
                // Replace all expressions of all involved repositories
                foreach ($objectToTableFields as $table => $tableFields) {
                    foreach ($tableFields as $objFieldName => $sqlFieldName) {
                        $expression = \str_replace(
                            ':' . $table . '.' . $objFieldName . ':',
                            $this->db->quoteIdentifier($table . '.' . $sqlFieldName),
                            $expression,
                            $count,
                        );
                    }
                }

                // Replace all table names and insert the aliases
                foreach ($tableNames as $tableNameAlias => $tableNameReal) {
                    $expression = \str_replace(
                        ':' . $tableNameAlias . ':',
                        $this->db->quoteIdentifier($tableNameReal) . ' ' . $this->db->quoteIdentifier($tableNameAlias),
                        $expression,
                    );
                }

                // If we still have unresolved expressions, something went wrong
                if (\str_contains($expression, ':')) {
                    throw Debug::createException(
                        DBInvalidOptionException::class,
                        'Invalid "tables" / table join definition, ' .
                        'unconverted objects/table names found in expression: ' .
                        Debug::sanitizeData($expression),
                        ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                    );
                }

                // Add expression to from tables
                if ($values === null) {
                    $tablesProcessed[] = $expression;
                } else {
                    $tablesProcessed[$expression] = $values;
                }
            }
        }

        return $tablesProcessed;
    }

    /**
     * Prepare the WHERE clauses for SQL component
     */
    protected function preprocessWhere(array $whereOptions, array $objectToTableFields): array
    {
        // List of finished WHERE expressions, to be imploded with ANDs
        $whereProcessed = [];

        // Go through table selection
        foreach ($whereOptions as $expression => $values) {
            // Switch around expression and values if there are no values
            if (\is_int($expression)) {
                $expression = $values;
                $values = null;
            }

            // Expression always has to be a string
            if (!\is_string($expression)) {
                throw Debug::createException(
                    DBInvalidOptionException::class,
                    'Invalid "where" definition, expression is not a string: ' .
                    Debug::sanitizeData($expression),
                    ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                );
            }

            // No expression, only a table field name
            if (!\str_contains($expression, ':')) {
                // Values have to be defined for us to make a predefined equals query
                if (isset($values)) {
                    // Get separated table and field parts
                    $fieldParts = \explode('.', $expression);

                    // Field was not found
                    if (!isset($fieldParts[1]) || !isset($objectToTableFields[$fieldParts[0]][$fieldParts[1]])) {
                        throw Debug::createException(
                            DBInvalidOptionException::class,
                            'Invalid "where" definition, field name not found: ' .
                            Debug::sanitizeData($expression),
                            ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                        );
                    }

                    // Convert field name
                    $expression = $fieldParts[0] . '.' . $objectToTableFields[$fieldParts[0]][$fieldParts[1]];
                }
            } else { // Freestyle expression
                // Replace all expressions of all involved repositories
                foreach ($objectToTableFields as $table => $tableFields) {
                    foreach ($tableFields as $objFieldName => $sqlFieldName) {
                        $expression = \str_replace(
                            ':' . $table . '.' . $objFieldName . ':',
                            $this->db->quoteIdentifier($table . '.' . $sqlFieldName),
                            $expression,
                            $count,
                        );
                    }
                }

                // If we still have unresolved expressions, something went wrong
                if (\str_contains($expression, ':')) {
                    throw Debug::createException(
                        DBInvalidOptionException::class,
                        'Invalid "where" definition, unresolved colons remain in expression: ' .
                        Debug::sanitizeData($expression),
                        ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                    );
                }
            }

            // Add the where definition to the processed list
            if (isset($values)) {
                $whereProcessed[$expression] = $values;
            } else {
                $whereProcessed[] = $expression;
            }
        }

        return $whereProcessed;
    }

    /**
     * Build GROUP BY clause and add query values
     */
    private function preprocessGroup(array $groupByOptions, array $objectToTableFields): array
    {
        // List of finished WHERE expressions, to be imploded with ANDs
        $groupByProcessed = [];

        // Go through table selection
        foreach ($groupByOptions as $expression => $values) {
            // Switch around expression and values if there are no values
            if (\is_int($expression)) {
                $expression = $values;
                $values = null;
            }

            // Expression always has to be a string
            if (!\is_string($expression)) {
                throw Debug::createException(
                    DBInvalidOptionException::class,
                    'Invalid "group" / group by definition, expression is not a string: ' .
                    Debug::sanitizeData($expression),
                    ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                );
            }

            // No expression, only a table field name
            if (!\str_contains($expression, ':')) {
                // Get separated table and field parts
                $fieldParts = \explode('.', $expression);

                // Field was not found
                if (!isset($objectToTableFields[$fieldParts[0]][$fieldParts[1]])) {
                    throw Debug::createException(
                        DBInvalidOptionException::class,
                        'Invalid "group" / group by definition, field name not found in any repository: ' .
                        Debug::sanitizeData($expression) . ' within ' . Debug::sanitizeData($groupByOptions),
                        ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                    );
                }

                $groupByProcessed[] = $fieldParts[0] . '.' . $objectToTableFields[$fieldParts[0]][$fieldParts[1]];
            } else { // Freestyle expression
                // Replace all expressions of all involved repositories
                foreach ($objectToTableFields as $table => $tableFields) {
                    foreach ($tableFields as $objFieldName => $sqlFieldName) {
                        $expression = \str_replace(
                            ':' . $table . '.' . $objFieldName . ':',
                            $this->db->quoteIdentifier($table . '.' . $sqlFieldName),
                            $expression,
                            $count,
                        );
                    }
                }

                // If we still have unresolved expressions, something went wrong
                if (\str_contains($expression, ':')) {
                    throw Debug::createException(
                        DBInvalidOptionException::class,
                        'Invalid "where" definition, unresolved colons remain in expression: ' .
                        Debug::sanitizeData($expression),
                        ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                    );
                }

                $groupByProcessed[] = $expression;
            }
        }

        return $groupByProcessed;
    }

    /**
     * Prepare the ORDER BY clauses for SQL component
     */
    protected function preprocessOrder(array $orderOptions, array $objectToTableFields): array
    {
        // List of finished WHERE expressions, to be imploded with ANDs
        $orderProcessed = [];

        // Go through table selection
        foreach ($orderOptions as $expression => $direction) {
            // If there is no explicit order we set it to ASC
            if (\is_int($expression)) {
                $expression = $direction;
                $direction = null;
            }

            // Expression always has to be a string
            if (!\is_string($expression)) {
                throw Debug::createException(
                    DBInvalidOptionException::class,
                    'Invalid "order" / order by definition, expression is not a string: ' .
                    Debug::sanitizeData($expression),
                    ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                );
            }

            // No expression, only a table field name
            if (!\str_contains($expression, ':')) {
                // Get separated table and field parts
                $fieldParts = \explode('.', $expression);

                // Field was found - convert it
                if (\count($fieldParts) === 2 && isset($objectToTableFields[$fieldParts[0]][$fieldParts[1]])) {
                    $expression = $fieldParts[0] . '.' . $objectToTableFields[$fieldParts[0]][$fieldParts[1]];
                }
            } else { // Freestyle expression
                // Replace all field names with the sql field name and escape characters around it
                foreach ($objectToTableFields as $table => $tableFields) {
                    foreach ($tableFields as $objFieldName => $sqlFieldName) {
                        $expression = \str_replace(
                            ':' . $table . '.' . $objFieldName . ':',
                            \chr(27) . $table . '.' . $sqlFieldName . \chr(27),
                            $expression,
                            $count,
                        );
                    }
                }

                // If we still have unresolved expressions, something went wrong
                if (\str_contains($expression, ':')) {
                    throw Debug::createException(
                        DBInvalidOptionException::class,
                        'Invalid "order" / order by definition, unconverted object names found in expression: ' .
                        Debug::sanitizeData($expression),
                        ignoreClasses: [MultiRepositoryReadOnlyInterface::class, BuilderInterface::class],
                    );
                }

                // Replace the escape markers back to colons
                $expression = \str_replace(\chr(27), ':', $expression);
            }

            // Add order entry to processed list
            if ($direction === null) {
                $orderProcessed[] = $expression;
            } else {
                $orderProcessed[$expression] = $direction;
            }
        }

        return $orderProcessed;
    }
}