From 5c4595f8c3010910fc134a003ef22e03cb6d032f Mon Sep 17 00:00:00 2001 From: Henry Paradiz Date: Sat, 28 Mar 2026 17:57:50 -0700 Subject: [PATCH 1/6] Refactor SQL create table generator to lower complexity --- src/IO/Database/Writer/AbstractSqlWriter.php | 108 ++++++++ src/IO/Database/Writer/MySQL.php | 260 +++++++++---------- src/IO/Database/Writer/PostgreSQL.php | 202 +++++++------- src/IO/Database/Writer/SQLite.php | 188 +++++++------- 4 files changed, 432 insertions(+), 326 deletions(-) create mode 100644 src/IO/Database/Writer/AbstractSqlWriter.php diff --git a/src/IO/Database/Writer/AbstractSqlWriter.php b/src/IO/Database/Writer/AbstractSqlWriter.php new file mode 100644 index 0000000..5f288f1 --- /dev/null +++ b/src/IO/Database/Writer/AbstractSqlWriter.php @@ -0,0 +1,108 @@ + $field) { + if ($field['columnName'] === 'RevisionID') { + continue; + } + + $callback($fieldId, $field); + } + } + + protected static function normalizeFieldOptions(string $recordClass, string $fieldName): array + { + $field = static::getAggregateFieldOptions($recordClass, $fieldName); + $rootClass = $recordClass::getRootClassName(); + + if ($rootClass && !$rootClass::fieldExists($fieldName)) { + $field['notnull'] = false; + } + + if ( + $field['columnName'] == 'Class' + && $field['type'] == 'enum' + && !in_array($rootClass, $field['values']) + && !count($rootClass::getStaticSubClasses()) + ) { + array_unshift($field['values'], $rootClass); + } + + return $field; + } + + protected static function getVariableCharacterType(array $field): string + { + return sprintf( + !$field['length'] || $field['type'] == 'varchar' ? 'varchar(%u)' : 'char(%u)', + $field['length'] ? $field['length'] : 255 + ); + } + + protected static function quoteEnumValues(array $field, string $quote = '"'): string + { + $escapedValues = array_map([static::class, 'escape'], $field['values']); + + return join($quote . ',' . $quote, $escapedValues); + } + + protected static function getTranslatedIndexes(string $recordClass, bool $historyVariant): array + { + $indexes = $historyVariant ? [] : $recordClass::$indexes; + + foreach ($indexes as &$index) { + foreach ($index['fields'] as &$indexField) { + $indexField = $recordClass::getColumnName($indexField); + } + } + + return $indexes; + } + + protected static function hasContextFields(string $recordClass): bool + { + return $recordClass::fieldExists('ContextClass') && $recordClass::fieldExists('ContextID'); + } + + protected static function getTargetTableName(string $recordClass, bool $historyVariant): string + { + return $historyVariant ? $recordClass::getHistoryTable() : $recordClass::$tableName; + } + + protected static function isVersionedRecord(string $recordClass): bool + { + return is_subclass_of($recordClass, 'VersionedRecord'); + } + + protected static function appendContextIndex(array &$statements, string $recordClass): void + { + if (static::hasContextFields($recordClass)) { + $statements[] = static::getContextIndex($recordClass); + } + } + + protected static function getStandardIndexes(string $recordClass, bool $historyVariant): array + { + return static::getTranslatedIndexes($recordClass, $historyVariant); + } +} diff --git a/src/IO/Database/Writer/MySQL.php b/src/IO/Database/Writer/MySQL.php index d6b3112..85b6223 100644 --- a/src/IO/Database/Writer/MySQL.php +++ b/src/IO/Database/Writer/MySQL.php @@ -19,9 +19,22 @@ * @author Henry Paradiz * */ -class MySQL +class MySQL extends AbstractSqlWriter { - protected static $aggregateFieldConfigs; + protected const DIRECT_SQL_TYPES = [ + 'boolean' => 'boolean', + 'float' => 'float', + 'double' => 'double', + 'clob' => 'text', + 'serialized' => 'text', + 'json' => 'text', + 'blob' => 'blob', + 'timestamp' => 'timestamp', + 'datetime' => 'datetime', + 'time' => 'time', + 'date' => 'date', + 'year' => 'year', + ]; /** * This is how MySQL escapes it's string under the hood. @@ -42,13 +55,8 @@ public static function escape($str) public static function compileFields($recordClass, $historyVariant = false) { $queryString = []; - $fields = static::getAggregateFieldOptions($recordClass); - - foreach ($fields as $fieldId => $field) { - if ($field['columnName'] == 'RevisionID') { - continue; - } + static::eachNonRevisionField($recordClass, function ($fieldId, $field) use (&$queryString, $recordClass, $historyVariant) { $queryString[] = static::getFieldDefinition($recordClass, $fieldId, $historyVariant); if (!empty($field['primary'])) { @@ -66,7 +74,7 @@ public static function compileFields($recordClass, $historyVariant = false) if (!empty($field['index']) && !$historyVariant) { $queryString[] = 'KEY `'.$field['columnName'].'` (`'.$field['columnName'].'`)'; } - } + }); return $queryString; } @@ -74,17 +82,12 @@ public static function compileFields($recordClass, $historyVariant = false) public static function getFullTextColumns($recordClass) { $fulltextColumns = []; - $fields = static::getAggregateFieldOptions($recordClass); - - foreach ($fields as $fieldId => $field) { - if ($field['columnName'] == 'RevisionID') { - continue; - } + static::eachNonRevisionField($recordClass, function ($fieldId, $field) use (&$fulltextColumns) { if (!empty($field['fulltext'])) { $fulltextColumns[] = $field['columnName']; } - } + }); return $fulltextColumns; } @@ -103,36 +106,45 @@ public static function getContextIndex($recordClass) */ public static function getCreateTable($recordClass, $historyVariant = false) { - $indexes = $historyVariant ? [] : $recordClass::$indexes; + $indexes = static::getStandardIndexes($recordClass, $historyVariant); $fulltextColumns = []; - $queryString = []; + $queryString = static::getMySqlBaseStatements($recordClass, $historyVariant); - - // history table revisionID field - if ($historyVariant) { - $queryString[] = '`RevisionID` int(10) unsigned NOT NULL auto_increment'; - $queryString[] = 'PRIMARY KEY (`RevisionID`)'; + if (!$historyVariant) { + static::appendContextIndex($queryString, $recordClass); + $fulltextColumns = static::getFullTextColumns($recordClass); } - $queryString = array_merge($queryString, static::compileFields($recordClass, $historyVariant)); + static::appendMySqlIndexes($queryString, $fulltextColumns, $indexes); - if (!$historyVariant) { - // If ContextClass && ContextID are members of this model let's index them - if ($recordClass::fieldExists('ContextClass') && $recordClass::fieldExists('ContextID')) { - $queryString[] = static::getContextIndex($recordClass); - } + $createSQL = sprintf( + "CREATE TABLE IF NOT EXISTS `%s` (\n\t%s\n) ENGINE=MyISAM DEFAULT CHARSET=utf8;", + static::getTargetTableName($recordClass, $historyVariant), + join("\n\t,", $queryString) + ); - $fulltextColumns = static::getFullTextColumns($recordClass); + // append history table SQL + if (!$historyVariant && static::isVersionedRecord($recordClass)) { + $createSQL .= PHP_EOL.PHP_EOL.PHP_EOL.static::getCreateTable($recordClass, true); } + return $createSQL; + } - // compile indexes - foreach ($indexes as $indexName => $index) { + protected static function getMySqlBaseStatements(string $recordClass, bool $historyVariant): array + { + $queryString = []; - // translate field names - foreach ($index['fields'] as &$indexField) { - $indexField = $recordClass::getColumnName($indexField); - } + if ($historyVariant) { + $queryString[] = '`RevisionID` int(10) unsigned NOT NULL auto_increment'; + $queryString[] = 'PRIMARY KEY (`RevisionID`)'; + } + + return array_merge($queryString, static::compileFields($recordClass, $historyVariant)); + } + protected static function appendMySqlIndexes(array &$queryString, array &$fulltextColumns, array $indexes): void + { + foreach ($indexes as $indexName => $index) { if (!empty($index['fulltext'])) { $fulltextColumns = array_unique(array_merge($fulltextColumns, $index['fields'])); continue; @@ -149,98 +161,80 @@ public static function getCreateTable($recordClass, $historyVariant = false) if (!empty($fulltextColumns)) { $queryString[] = 'FULLTEXT KEY `FULLTEXT` (`'.join('`,`', $fulltextColumns).'`)'; } + } + public static function getSQLType($field) + { + if (isset(static::DIRECT_SQL_TYPES[$field['type']])) { + return static::DIRECT_SQL_TYPES[$field['type']]; + } - $createSQL = sprintf( - "CREATE TABLE IF NOT EXISTS `%s` (\n\t%s\n) ENGINE=MyISAM DEFAULT CHARSET=utf8;", - $historyVariant ? $recordClass::getHistoryTable() : $recordClass::$tableName, - join("\n\t,", $queryString) - ); + if (in_array($field['type'], ['tinyint', 'smallint', 'mediumint', 'bigint'], true)) { + return static::getMySqlSizedIntegerType($field); + } - // append history table SQL - if (!$historyVariant && is_subclass_of($recordClass, 'VersionedRecord')) { - $createSQL .= PHP_EOL.PHP_EOL.PHP_EOL.static::getCreateTable($recordClass, true); + if (in_array($field['type'], ['uint', 'int', 'integer'], true)) { + return static::getMySqlIntegerType($field); } - return $createSQL; - } - public static function getSQLType($field) - { - switch ($field['type']) { - case 'boolean': - return 'boolean'; - case 'tinyint': - case 'smallint': - case 'mediumint': - case 'bigint': - return $field['type'].($field['unsigned'] ? ' unsigned' : '').($field['zerofill'] ? ' zerofill' : ''); - case 'uint': - $field['unsigned'] = true; - // no break - case 'int': - case 'integer': - return 'int'.($field['unsigned'] ? ' unsigned' : '').(!empty($field['zerofill']) ? ' zerofill' : ''); - case 'decimal': - return sprintf('decimal(%s,%s)', $field['precision'], $field['scale']); - case 'float': - return 'float'; - case 'double': - return 'double'; - - case 'password': - case 'string': - case 'varchar': - case 'list': - return sprintf(!$field['length'] || $field['type'] == 'varchar' ? 'varchar(%u)' : 'char(%u)', $field['length'] ? $field['length'] : 255); - case 'clob': - case 'serialized': - case 'json': - return 'text'; - case 'blob': - return 'blob'; - case 'binary': - return sprintf('binary(%s)', isset($field['length']) ? $field['length'] : 1); - - case 'timestamp': - return 'timestamp'; - case 'datetime': - return 'datetime'; - case 'time': - return 'time'; - case 'date': - return 'date'; - case 'year': - return 'year'; - - case 'enum': - return sprintf('enum("%s")', join('","', array_map([static::class,'escape'], $field['values']))); - - case 'set': - return sprintf('set("%s")', join('","', array_map([static::class,'escape'], $field['values']))); - - default: - throw new Exception("getSQLType: unhandled type $field[type]"); + if ($field['type'] === 'decimal') { + return sprintf('decimal(%s,%s)', $field['precision'], $field['scale']); + } + + if (in_array($field['type'], ['password', 'string', 'varchar', 'list'], true)) { + return static::getVariableCharacterType($field); + } + + if ($field['type'] === 'binary') { + return sprintf('binary(%s)', isset($field['length']) ? $field['length'] : 1); } + + if ($field['type'] === 'enum') { + return sprintf('enum("%s")', static::quoteEnumValues($field)); + } + + if ($field['type'] === 'set') { + return sprintf('set("%s")', static::quoteEnumValues($field)); + } + + throw new Exception("getSQLType: unhandled type $field[type]"); } public static function getFieldDefinition($recordClass, $fieldName, $historyVariant = false) { - $field = static::getAggregateFieldOptions($recordClass, $fieldName); - $rootClass = $recordClass::getRootClassName(); + $field = static::normalizeFieldOptions($recordClass, $fieldName); + $fieldDef = static::buildMySqlFieldPrefix($field); + $fieldDef = static::appendMySqlFieldEncoding($fieldDef, $field); + $fieldDef .= ' '.($field['notnull'] ? 'NOT NULL' : 'NULL'); - // force notnull=false on non-rootclass fields - if ($rootClass && !$rootClass::fieldExists($fieldName)) { - $field['notnull'] = false; - } + return static::appendMySqlFieldDefault($fieldDef, $field, $historyVariant); + } - // auto-prepend class type - if ($field['columnName'] == 'Class' && $field['type'] == 'enum' && !in_array($rootClass, $field['values']) && !count($rootClass::getStaticSubClasses())) { - array_unshift($field['values'], $rootClass); + protected static function getMySqlSizedIntegerType(array $field): string + { + return $field['type'] + . ($field['unsigned'] ? ' unsigned' : '') + . (!empty($field['zerofill']) ? ' zerofill' : ''); + } + + protected static function getMySqlIntegerType(array $field): string + { + if ($field['type'] === 'uint') { + $field['unsigned'] = true; } - $fieldDef = '`'.$field['columnName'].'`'; - $fieldDef .= ' '.static::getSQLType($field); + return 'int' + . ($field['unsigned'] ? ' unsigned' : '') + . (!empty($field['zerofill']) ? ' zerofill' : ''); + } + protected static function buildMySqlFieldPrefix(array $field): string + { + return '`' . $field['columnName'] . '` ' . static::getSQLType($field); + } + + protected static function appendMySqlFieldEncoding(string $fieldDef, array $field): string + { if (!empty($field['charset'])) { $fieldDef .= " CHARACTER SET $field[charset]"; } @@ -249,35 +243,31 @@ public static function getFieldDefinition($recordClass, $fieldName, $historyVari $fieldDef .= " COLLATE $field[collate]"; } - $fieldDef .= ' '.($field['notnull'] ? 'NOT NULL' : 'NULL'); + return $fieldDef; + } + protected static function appendMySqlFieldDefault(string $fieldDef, array $field, bool $historyVariant): string + { if ($field['autoincrement'] && !$historyVariant) { - $fieldDef .= ' auto_increment'; - } elseif (($field['type'] == 'timestamp') && ($field['default'] == 'CURRENT_TIMESTAMP')) { - $fieldDef .= ' default CURRENT_TIMESTAMP'; - } elseif (empty($field['notnull']) && ($field['default'] == null)) { - $fieldDef .= ' default NULL'; - } elseif (isset($field['default'])) { - if ($field['type'] == 'boolean') { - $fieldDef .= ' default '.($field['default'] ? 1 : 0); - } else { - $fieldDef .= ' default "'.static::escape($field['default']).'"'; - } + return $fieldDef . ' auto_increment'; } - return $fieldDef; - } + if (($field['type'] == 'timestamp') && ($field['default'] == 'CURRENT_TIMESTAMP')) { + return $fieldDef . ' default CURRENT_TIMESTAMP'; + } - protected static function getAggregateFieldOptions($recordClass, $field = null) - { - if (!isset(static::$aggregateFieldConfigs[$recordClass])) { - static::$aggregateFieldConfigs[$recordClass] = $recordClass::getClassFields(); + if (empty($field['notnull']) && ($field['default'] == null)) { + return $fieldDef . ' default NULL'; } - if ($field) { - return static::$aggregateFieldConfigs[$recordClass][$field]; - } else { - return static::$aggregateFieldConfigs[$recordClass]; + if (!isset($field['default'])) { + return $fieldDef; } + + if ($field['type'] == 'boolean') { + return $fieldDef . ' default ' . ($field['default'] ? 1 : 0); + } + + return $fieldDef . ' default "' . static::escape($field['default']) . '"'; } } diff --git a/src/IO/Database/Writer/PostgreSQL.php b/src/IO/Database/Writer/PostgreSQL.php index 675ecd0..d33d516 100644 --- a/src/IO/Database/Writer/PostgreSQL.php +++ b/src/IO/Database/Writer/PostgreSQL.php @@ -14,18 +14,37 @@ class PostgreSQL extends MySQL { + protected const DIRECT_SQL_TYPES = [ + 'boolean' => 'boolean', + 'smallint' => 'smallint', + 'mediumint' => 'integer', + 'int' => 'integer', + 'integer' => 'integer', + 'uint' => 'integer', + 'year' => 'integer', + 'bigint' => 'bigint', + 'float' => 'real', + 'double' => 'double precision', + 'clob' => 'text', + 'serialized' => 'text', + 'json' => 'text', + 'enum' => 'text', + 'set' => 'text', + 'blob' => 'bytea', + 'binary' => 'bytea', + 'timestamp' => 'timestamp', + 'datetime' => 'timestamp', + 'time' => 'time', + 'date' => 'date', + ]; + public static function compileFields($recordClass, $historyVariant = false) { $queryString = []; - $fields = static::getAggregateFieldOptions($recordClass); - - foreach ($fields as $fieldId => $field) { - if ($field['columnName'] == 'RevisionID') { - continue; - } + static::eachNonRevisionField($recordClass, function ($fieldId) use (&$queryString, $recordClass, $historyVariant) { $queryString[] = static::getFieldDefinition($recordClass, $fieldId, $historyVariant); - } + }); return $queryString; } @@ -37,25 +56,43 @@ public static function getContextIndex($recordClass) public static function getCreateTable($recordClass, $historyVariant = false) { - $indexes = $historyVariant ? [] : $recordClass::$indexes; - $queryString = []; + $indexes = static::getStandardIndexes($recordClass, $historyVariant); + $queryString = static::getPostgreSqlBaseStatements($recordClass, $historyVariant); $postCreateStatements = []; - if ($historyVariant) { - $queryString[] = '"RevisionID" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY'; + if (!$historyVariant) { + static::appendContextIndex($postCreateStatements, $recordClass); } - $queryString = array_merge($queryString, static::compileFields($recordClass, $historyVariant)); + static::appendPostgreSqlIndexes($postCreateStatements, $recordClass, $indexes); + + $createSQL = sprintf( + "CREATE TABLE IF NOT EXISTS \"%s\" (\n\t%s\n);", + static::getTargetTableName($recordClass, $historyVariant), + join("\n\t,", $queryString) + ); - if (!$historyVariant && $recordClass::fieldExists('ContextClass') && $recordClass::fieldExists('ContextID')) { - $postCreateStatements[] = static::getContextIndex($recordClass); + if (!empty($postCreateStatements)) { + $createSQL .= PHP_EOL . PHP_EOL . join(";" . PHP_EOL, $postCreateStatements) . ';'; } - foreach ($indexes as $indexName => $index) { - foreach ($index['fields'] as &$indexField) { - $indexField = $recordClass::getColumnName($indexField); - } + return $createSQL; + } + + protected static function getPostgreSqlBaseStatements(string $recordClass, bool $historyVariant): array + { + $queryString = []; + + if ($historyVariant) { + $queryString[] = '"RevisionID" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY'; + } + + return array_merge($queryString, static::compileFields($recordClass, $historyVariant)); + } + protected static function appendPostgreSqlIndexes(array &$postCreateStatements, string $recordClass, array $indexes): void + { + foreach ($indexes as $indexName => $index) { if (!empty($index['fulltext'])) { continue; } @@ -68,114 +105,81 @@ public static function getCreateTable($recordClass, $historyVariant = false) join('","', $index['fields']) ); } + } - $createSQL = sprintf( - "CREATE TABLE IF NOT EXISTS \"%s\" (\n\t%s\n);", - $historyVariant ? $recordClass::getHistoryTable() : $recordClass::$tableName, - join("\n\t,", $queryString) - ); + public static function getSQLType($field) + { + if (isset(static::DIRECT_SQL_TYPES[$field['type']])) { + return static::DIRECT_SQL_TYPES[$field['type']]; + } - if (!empty($postCreateStatements)) { - $createSQL .= PHP_EOL . PHP_EOL . join(";" . PHP_EOL, $postCreateStatements) . ';'; + if ($field['type'] === 'tinyint') { + return 'smallint'; } - return $createSQL; - } + if ($field['type'] === 'decimal') { + return sprintf('numeric(%s,%s)', $field['precision'], $field['scale']); + } - public static function getSQLType($field) - { - switch ($field['type']) { - case 'boolean': - return 'boolean'; - case 'tinyint': - case 'smallint': - return 'smallint'; - case 'mediumint': - case 'int': - case 'integer': - case 'uint': - case 'year': - return 'integer'; - case 'bigint': - return 'bigint'; - case 'decimal': - return sprintf('numeric(%s,%s)', $field['precision'], $field['scale']); - case 'float': - return 'real'; - case 'double': - return 'double precision'; - - case 'password': - case 'string': - case 'varchar': - case 'list': - return sprintf(!$field['length'] || $field['type'] == 'varchar' ? 'varchar(%u)' : 'char(%u)', $field['length'] ? $field['length'] : 255); - case 'clob': - case 'serialized': - case 'json': - case 'enum': - case 'set': - return 'text'; - case 'blob': - case 'binary': - return 'bytea'; - - case 'timestamp': - case 'datetime': - return 'timestamp'; - case 'time': - return 'time'; - case 'date': - return 'date'; - - default: - throw new Exception("getSQLType: unhandled type $field[type]"); + if (in_array($field['type'], ['password', 'string', 'varchar', 'list'], true)) { + return static::getVariableCharacterType($field); } + + throw new Exception("getSQLType: unhandled type $field[type]"); } public static function getFieldDefinition($recordClass, $fieldName, $historyVariant = false) { - $field = static::getAggregateFieldOptions($recordClass, $fieldName); - $rootClass = $recordClass::getRootClassName(); + $field = static::normalizeFieldOptions($recordClass, $fieldName); - if ($rootClass && !$rootClass::fieldExists($fieldName)) { - $field['notnull'] = false; + if (!empty($field['primary']) && !empty($field['autoincrement']) && !$historyVariant) { + return '"' . $field['columnName'] . '" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY'; } - if ($field['columnName'] == 'Class' && $field['type'] == 'enum' && !in_array($rootClass, $field['values']) && !count($rootClass::getStaticSubClasses())) { - array_unshift($field['values'], $rootClass); - } + $fieldDef = static::buildPostgreSqlFieldPrefix($field, $historyVariant); + $fieldDef .= ' ' . ($field['notnull'] ? 'NOT NULL' : 'NULL'); + $fieldDef = static::appendPostgreSqlFieldDefault($fieldDef, $field); - if (!empty($field['primary']) && !empty($field['autoincrement']) && !$historyVariant) { - return '"' . $field['columnName'] . '" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY'; + if (!empty($field['unique']) && !$historyVariant) { + $fieldDef .= ' UNIQUE'; } + return $fieldDef; + } + + protected static function buildPostgreSqlFieldPrefix(array $field, bool $historyVariant): string + { $fieldDef = '"' . $field['columnName'] . '" ' . static::getSQLType($field); if (!empty($field['primary']) && !$historyVariant) { $fieldDef .= ' PRIMARY KEY'; } - $fieldDef .= ' ' . ($field['notnull'] ? 'NOT NULL' : 'NULL'); + return $fieldDef; + } + protected static function appendPostgreSqlFieldDefault(string $fieldDef, array $field): string + { if (($field['type'] == 'timestamp') && ($field['default'] == 'CURRENT_TIMESTAMP')) { - $fieldDef .= ' DEFAULT CURRENT_TIMESTAMP'; - } elseif (empty($field['notnull']) && ($field['default'] == null)) { - $fieldDef .= ' DEFAULT NULL'; - } elseif (isset($field['default'])) { - if ($field['type'] == 'boolean') { - $fieldDef .= ' DEFAULT ' . ($field['default'] ? 'TRUE' : 'FALSE'); - } elseif (in_array($field['type'], ['int', 'integer', 'uint', 'tinyint', 'smallint', 'mediumint', 'bigint', 'float', 'double', 'decimal'], true)) { - $fieldDef .= ' DEFAULT ' . $field['default']; - } else { - $fieldDef .= " DEFAULT '" . str_replace("'", "''", (string) $field['default']) . "'"; - } + return $fieldDef . ' DEFAULT CURRENT_TIMESTAMP'; } - if (!empty($field['unique']) && !$historyVariant) { - $fieldDef .= ' UNIQUE'; + if (empty($field['notnull']) && ($field['default'] == null)) { + return $fieldDef . ' DEFAULT NULL'; } - return $fieldDef; + if (!isset($field['default'])) { + return $fieldDef; + } + + if ($field['type'] == 'boolean') { + return $fieldDef . ' DEFAULT ' . ($field['default'] ? 'TRUE' : 'FALSE'); + } + + if (in_array($field['type'], ['int', 'integer', 'uint', 'tinyint', 'smallint', 'mediumint', 'bigint', 'float', 'double', 'decimal'], true)) { + return $fieldDef . ' DEFAULT ' . $field['default']; + } + + return $fieldDef . " DEFAULT '" . str_replace("'", "''", (string) $field['default']) . "'"; } } diff --git a/src/IO/Database/Writer/SQLite.php b/src/IO/Database/Writer/SQLite.php index c5c0af3..2392f58 100644 --- a/src/IO/Database/Writer/SQLite.php +++ b/src/IO/Database/Writer/SQLite.php @@ -21,22 +21,43 @@ */ class SQLite extends MySQL { + protected const DIRECT_SQL_TYPES = [ + 'boolean' => 'INTEGER', + 'tinyint' => 'INTEGER', + 'smallint' => 'INTEGER', + 'mediumint' => 'INTEGER', + 'bigint' => 'INTEGER', + 'uint' => 'INTEGER', + 'int' => 'INTEGER', + 'integer' => 'INTEGER', + 'year' => 'INTEGER', + 'decimal' => 'REAL', + 'float' => 'REAL', + 'double' => 'REAL', + 'clob' => 'TEXT', + 'serialized' => 'TEXT', + 'json' => 'TEXT', + 'enum' => 'TEXT', + 'set' => 'TEXT', + 'timestamp' => 'TEXT', + 'datetime' => 'TEXT', + 'time' => 'TEXT', + 'date' => 'TEXT', + 'blob' => 'BLOB', + 'binary' => 'BLOB', + ]; + public static function compileFields($recordClass, $historyVariant = false) { $queryString = []; - $fields = static::getAggregateFieldOptions($recordClass); - - foreach ($fields as $fieldId => $field) { - if ($field['columnName'] == 'RevisionID') { - continue; - } + static::eachNonRevisionField($recordClass, function ($fieldId, $field) use (&$queryString, $recordClass, $historyVariant) { $queryString[] = static::getFieldDefinition($recordClass, $fieldId, $historyVariant); if (!empty($field['unique']) && !$historyVariant) { $queryString[] = 'UNIQUE (`'.$field['columnName'].'`)'; } - } + }); return $queryString; } @@ -48,45 +69,23 @@ public static function getContextIndex($recordClass) public static function getCreateTable($recordClass, $historyVariant = false) { - $indexes = $historyVariant ? [] : $recordClass::$indexes; - $queryString = []; + $indexes = static::getStandardIndexes($recordClass, $historyVariant); + $queryString = static::getSqliteBaseStatements($recordClass, $historyVariant); $postCreateStatements = []; - if ($historyVariant) { - $queryString[] = '`RevisionID` INTEGER PRIMARY KEY AUTOINCREMENT'; - } - - $queryString = array_merge($queryString, static::compileFields($recordClass, $historyVariant)); - - if (!$historyVariant && $recordClass::fieldExists('ContextClass') && $recordClass::fieldExists('ContextID')) { - $postCreateStatements[] = static::getContextIndex($recordClass); + if (!$historyVariant) { + static::appendContextIndex($postCreateStatements, $recordClass); } - foreach ($indexes as $indexName => $index) { - foreach ($index['fields'] as &$indexField) { - $indexField = $recordClass::getColumnName($indexField); - } - - if (!empty($index['fulltext'])) { - continue; - } - - $postCreateStatements[] = sprintf( - 'CREATE %sINDEX IF NOT EXISTS `%s` ON `%s` (`%s`)', - !empty($index['unique']) ? 'UNIQUE ' : '', - $indexName, - $recordClass::$tableName, - join('`,`', $index['fields']) - ); - } + static::appendSqliteIndexes($postCreateStatements, $recordClass, $indexes); $createSQL = sprintf( "CREATE TABLE IF NOT EXISTS `%s` (\n\t%s\n);", - $historyVariant ? $recordClass::getHistoryTable() : $recordClass::$tableName, + static::getTargetTableName($recordClass, $historyVariant), join("\n\t,", $queryString) ); - if (!$historyVariant && is_subclass_of($recordClass, 'VersionedRecord')) { + if (!$historyVariant && static::isVersionedRecord($recordClass)) { $postCreateStatements[] = static::getCreateTable($recordClass, true); } @@ -97,85 +96,90 @@ public static function getCreateTable($recordClass, $historyVariant = false) return $createSQL; } - public static function getSQLType($field) + protected static function getSqliteBaseStatements(string $recordClass, bool $historyVariant): array { - switch ($field['type']) { - case 'boolean': - return 'INTEGER'; - case 'tinyint': - case 'smallint': - case 'mediumint': - case 'bigint': - case 'uint': - case 'int': - case 'integer': - case 'year': - return 'INTEGER'; - case 'decimal': - case 'float': - case 'double': - return 'REAL'; - - case 'password': - case 'string': - case 'varchar': - case 'list': - case 'clob': - case 'serialized': - case 'json': - case 'enum': - case 'set': - case 'timestamp': - case 'datetime': - case 'time': - case 'date': - return 'TEXT'; - - case 'blob': - case 'binary': - return 'BLOB'; - - default: - throw new Exception("getSQLType: unhandled type $field[type]"); + $queryString = []; + + if ($historyVariant) { + $queryString[] = '`RevisionID` INTEGER PRIMARY KEY AUTOINCREMENT'; } + + return array_merge($queryString, static::compileFields($recordClass, $historyVariant)); } - public static function getFieldDefinition($recordClass, $fieldName, $historyVariant = false) + protected static function appendSqliteIndexes(array &$postCreateStatements, string $recordClass, array $indexes): void { - $field = static::getAggregateFieldOptions($recordClass, $fieldName); - $rootClass = $recordClass::getRootClassName(); + foreach ($indexes as $indexName => $index) { + if (!empty($index['fulltext'])) { + continue; + } + + $postCreateStatements[] = sprintf( + 'CREATE %sINDEX IF NOT EXISTS `%s` ON `%s` (`%s`)', + !empty($index['unique']) ? 'UNIQUE ' : '', + $indexName, + $recordClass::$tableName, + join('`,`', $index['fields']) + ); + } + } - if ($rootClass && !$rootClass::fieldExists($fieldName)) { - $field['notnull'] = false; + public static function getSQLType($field) + { + if (isset(static::DIRECT_SQL_TYPES[$field['type']])) { + return static::DIRECT_SQL_TYPES[$field['type']]; } - if ($field['columnName'] == 'Class' && $field['type'] == 'enum' && !in_array($rootClass, $field['values']) && !count($rootClass::getStaticSubClasses())) { - array_unshift($field['values'], $rootClass); + if (in_array($field['type'], ['password', 'string', 'varchar', 'list'], true)) { + return 'TEXT'; } + throw new Exception("getSQLType: unhandled type $field[type]"); + } + + public static function getFieldDefinition($recordClass, $fieldName, $historyVariant = false) + { + $field = static::normalizeFieldOptions($recordClass, $fieldName); + if (!empty($field['primary']) && !empty($field['autoincrement']) && !$historyVariant) { return '`'.$field['columnName'].'` INTEGER PRIMARY KEY AUTOINCREMENT'; } + $fieldDef = static::buildSqliteFieldPrefix($field, $historyVariant); + $fieldDef .= ' '.($field['notnull'] ? 'NOT NULL' : 'NULL'); + + return static::appendSqliteFieldDefault($fieldDef, $field); + } + + protected static function buildSqliteFieldPrefix(array $field, bool $historyVariant): string + { $fieldDef = '`'.$field['columnName'].'` '.static::getSQLType($field); if (!empty($field['primary']) && !$historyVariant) { $fieldDef .= ' PRIMARY KEY'; } - $fieldDef .= ' '.($field['notnull'] ? 'NOT NULL' : 'NULL'); + return $fieldDef; + } + protected static function appendSqliteFieldDefault(string $fieldDef, array $field): string + { if (($field['type'] == 'timestamp') && ($field['default'] == 'CURRENT_TIMESTAMP')) { - $fieldDef .= ' DEFAULT CURRENT_TIMESTAMP'; - } elseif (empty($field['notnull']) && ($field['default'] == null)) { - $fieldDef .= ' DEFAULT NULL'; - } elseif (isset($field['default'])) { - $fieldDef .= sprintf( - " DEFAULT '%s'", - str_replace("'", "''", (string) $field['default']) - ); + return $fieldDef . ' DEFAULT CURRENT_TIMESTAMP'; } - return $fieldDef; + if (empty($field['notnull']) && ($field['default'] == null)) { + return $fieldDef . ' DEFAULT NULL'; + } + + if (!isset($field['default'])) { + return $fieldDef; + } + + return sprintf( + "%s DEFAULT '%s'", + $fieldDef, + str_replace("'", "''", (string) $field['default']) + ); } } From 4007b0336d883280e98a450e84d4e086b5c488f2 Mon Sep 17 00:00:00 2001 From: Henry Paradiz Date: Sat, 28 Mar 2026 19:10:23 -0700 Subject: [PATCH 2/6] Getters converted to Classes Getter classes are registered with the Factory and become methods you can run on the Factory. The existing Getters trait for ActiveRecord models scans Factory for Getters and attached them to itself as well providing $className::$methodName() as static calls while using a Factory backend. --- src/Controllers/RecordsRequestHandler.php | 2 +- src/Models/Factory.php | 674 +++--------------- src/Models/Factory/Getters/GetAll.php | 11 + src/Models/Factory/Getters/GetAllByClass.php | 11 + .../Factory/Getters/GetAllByContext.php | 24 + .../Factory/Getters/GetAllByContextObject.php | 13 + src/Models/Factory/Getters/GetAllByField.php | 11 + src/Models/Factory/Getters/GetAllByQuery.php | 11 + src/Models/Factory/Getters/GetAllByWhere.php | 11 + src/Models/Factory/Getters/GetAllRecords.php | 33 + .../Factory/Getters/GetAllRecordsByWhere.php | 70 ++ src/Models/Factory/Getters/GetByContext.php | 31 + .../Factory/Getters/GetByContextObject.php | 13 + src/Models/Factory/Getters/GetByField.php | 11 + src/Models/Factory/Getters/GetByHandle.php | 23 + src/Models/Factory/Getters/GetByID.php | 11 + src/Models/Factory/Getters/GetByQuery.php | 11 + src/Models/Factory/Getters/GetByWhere.php | 11 + .../Factory/Getters/GetRecordByField.php | 11 + .../Factory/Getters/GetRecordByWhere.php | 30 + .../Factory/Getters/GetTableByQuery.php | 11 + .../Factory/Getters/GetUniqueHandle.php | 37 + src/Models/Factory/Getters/ModelGetter.php | 198 +++++ src/Models/Getters.php | 299 +------- 24 files changed, 732 insertions(+), 836 deletions(-) create mode 100644 src/Models/Factory/Getters/GetAll.php create mode 100644 src/Models/Factory/Getters/GetAllByClass.php create mode 100644 src/Models/Factory/Getters/GetAllByContext.php create mode 100644 src/Models/Factory/Getters/GetAllByContextObject.php create mode 100644 src/Models/Factory/Getters/GetAllByField.php create mode 100644 src/Models/Factory/Getters/GetAllByQuery.php create mode 100644 src/Models/Factory/Getters/GetAllByWhere.php create mode 100644 src/Models/Factory/Getters/GetAllRecords.php create mode 100644 src/Models/Factory/Getters/GetAllRecordsByWhere.php create mode 100644 src/Models/Factory/Getters/GetByContext.php create mode 100644 src/Models/Factory/Getters/GetByContextObject.php create mode 100644 src/Models/Factory/Getters/GetByField.php create mode 100644 src/Models/Factory/Getters/GetByHandle.php create mode 100644 src/Models/Factory/Getters/GetByID.php create mode 100644 src/Models/Factory/Getters/GetByQuery.php create mode 100644 src/Models/Factory/Getters/GetByWhere.php create mode 100644 src/Models/Factory/Getters/GetRecordByField.php create mode 100644 src/Models/Factory/Getters/GetRecordByWhere.php create mode 100644 src/Models/Factory/Getters/GetTableByQuery.php create mode 100644 src/Models/Factory/Getters/GetUniqueHandle.php create mode 100644 src/Models/Factory/Getters/ModelGetter.php diff --git a/src/Controllers/RecordsRequestHandler.php b/src/Controllers/RecordsRequestHandler.php index 8a2bc10..06b7fd4 100644 --- a/src/Controllers/RecordsRequestHandler.php +++ b/src/Controllers/RecordsRequestHandler.php @@ -112,7 +112,7 @@ public function handleRecordsRequest($action = false): ResponseInterface public function getRecordByHandle($handle) { $className = static::$recordClass; - if (method_exists($className, 'getByHandle')) { + if (is_callable([$className, 'getByHandle'])) { return $className::getByHandle($handle); } } diff --git a/src/Models/Factory.php b/src/Models/Factory.php index 459fe85..fbcdda5 100644 --- a/src/Models/Factory.php +++ b/src/Models/Factory.php @@ -10,12 +10,32 @@ namespace Divergence\Models; +use BadMethodCallException; use Exception; -use Divergence\Helpers\Util; use Divergence\Models\Factory\Instantiator; +use Divergence\Models\Factory\Getters\GetAll; +use Divergence\Models\Factory\Getters\GetAllByClass; +use Divergence\Models\Factory\Getters\GetAllByContext; +use Divergence\Models\Factory\Getters\GetAllByContextObject; +use Divergence\Models\Factory\Getters\GetAllByField; +use Divergence\Models\Factory\Getters\GetAllByQuery; +use Divergence\Models\Factory\Getters\GetAllByWhere; +use Divergence\Models\Factory\Getters\GetAllRecords; +use Divergence\Models\Factory\Getters\GetAllRecordsByWhere; +use Divergence\Models\Factory\Getters\GetByContext; +use Divergence\Models\Factory\Getters\GetByContextObject; +use Divergence\Models\Factory\Getters\GetByField; +use Divergence\Models\Factory\Getters\GetByHandle; +use Divergence\Models\Factory\Getters\GetByID; +use Divergence\Models\Factory\Getters\GetByQuery; +use Divergence\Models\Factory\Getters\GetByWhere; +use Divergence\Models\Factory\Getters\GetRecordByField; +use Divergence\Models\Factory\Getters\GetRecordByWhere; +use Divergence\Models\Factory\Getters\GetTableByQuery; +use Divergence\Models\Factory\Getters\GetUniqueHandle; +use Divergence\Models\Factory\Getters\ModelGetter; use Divergence\Models\Factory\ModelMetadata; use Divergence\IO\Database\Connections; -use Divergence\IO\Database\Query\Select; use PDO; class Factory @@ -40,6 +60,16 @@ class Factory */ protected static $metadata = []; + /** + * @var array + */ + protected $getterClasses = []; + + /** + * @var array + */ + protected $getters = []; + /** * Fully-qualified model class name. * @@ -74,20 +104,91 @@ public function __construct(string $modelClass) { $this->modelClass = $modelClass; + $this->registerGetterClasses(); + $this->setModelMetadata(); + $this->configureConnection(); + $this->setInstantiator(); + } + + protected function registerGetterClasses(): void + { + $this->getterClasses = []; + + foreach ([ + GetByContextObject::class, + GetByContext::class, + GetByHandle::class, + GetByID::class, + GetByField::class, + GetRecordByField::class, + GetByWhere::class, + GetRecordByWhere::class, + GetByQuery::class, + GetAllByClass::class, + GetAllByContextObject::class, + GetAllByContext::class, + GetAllByField::class, + GetAllByWhere::class, + GetAll::class, + GetAllRecords::class, + GetAllByQuery::class, + GetTableByQuery::class, + GetAllRecordsByWhere::class, + GetUniqueHandle::class, + ] as $className) { + $this->registerGetterClass($className); + } + } + + protected function registerGetterClass(string $className): void + { + $parts = explode('\\', $className); + $getterName = strtolower(lcfirst(end($parts))); + + if (isset($this->getterClasses[$getterName])) { + throw new Exception(sprintf('Getter method collision for %s', $getterName)); + } + + $this->getterClasses[$getterName] = $className; + } + + public function __call(string $name, array $arguments) + { + $getterName = strtolower($name); + + if (!isset($this->getterClasses[$getterName])) { + throw new BadMethodCallException(sprintf('Call to undefined method %s::%s()', static::class, $name)); + } + + if (!isset($this->getters[$getterName])) { + $getterClass = $this->getterClasses[$getterName]; + $this->getters[$getterName] = new $getterClass($this); + } + + return $this->getters[$getterName]->{$name}(...$arguments); + } + + protected function setModelMetadata(): void + { + $modelClass = $this->modelClass; + if (!isset(static::$metadata[$modelClass])) { static::$metadata[$modelClass] = ModelMetadata::get($modelClass); } $this->modelMetadata = static::$metadata[$modelClass]; + } + protected function configureConnection(): void + { if (Connections::$currentConnection === null) { Connections::setConnection(); } $connectionLabel = Connections::$currentConnection; - $storageClass = Connections::getConnectionType(); if (!isset(static::$storages[$connectionLabel])) { + $storageClass = Connections::getConnectionType(); static::$storages[$connectionLabel] = new $storageClass(); } @@ -95,12 +196,18 @@ public function __construct(string $modelClass) static::$connections[$connectionLabel] = static::$storages[$connectionLabel]->getConnection(); } + $this->storage = static::$storages[$connectionLabel]; + $this->connection = static::$connections[$connectionLabel]; + } + + protected function setInstantiator(): void + { + $modelClass = $this->modelClass; + if (!isset(static::$instantiators[$modelClass])) { static::$instantiators[$modelClass] = new Instantiator($this->modelMetadata); } - $this->storage = static::$storages[$connectionLabel]; - $this->connection = static::$connections[$connectionLabel]; $this->instantiator = static::$instantiators[$modelClass]; } @@ -117,53 +224,9 @@ public function getStorage() return $this->storage; } - protected function getColumnName($field) - { - return $this->modelMetadata->getColumnName($field); - } - - protected function mapFieldOrder($order) - { - $className = $this->modelClass; - - return $className::mapFieldOrder($order); - } - - protected function mapConditions($conditions) - { - $className = $this->modelClass; - - return $className::mapConditions($conditions); - } - - protected function fieldExists($field): bool - { - return $this->modelMetadata->fieldExists($field); - } - - protected function getHandleExceptionCallback(): array + public function getGetterClasses(): array { - return $this->modelMetadata->getHandleExceptionCallback(); - } - - protected function getPrimaryKeyName(): string - { - return $this->modelMetadata->getPrimaryKey(); - } - - protected function getHandleFieldName(): string - { - return $this->modelMetadata->getHandleField(); - } - - protected function getTableName(): string - { - return $this->modelMetadata->getTableName(); - } - - protected function getRootClass(): string - { - return $this->modelMetadata->getRootClass(); + return $this->getterClasses; } /** @@ -188,521 +251,18 @@ public function instantiateRecords($records) return $this->instantiator->instantiateRecords($records); } - /** - * Uses ContextClass and ContextID to get an object. - * Quick way to attach things to other objects in a one-to-one relationship - * - * @param array $record An array of database rows. - * @return Model|null An array of instantiated ActiveRecord models from the provided data. - */ - public function getByContextObject(ActiveRecord $Record, $options = []) - { - return $this->getByContext($Record::getRootClassName(), $Record->getPrimaryKeyValue(), $options); - } - - /** - * Same as getByContextObject but this method lets you specify the ContextClass manually. - * - * @param array $record An array of database rows. - * @return Model|null An array of instantiated ActiveRecord models from the provided data. - */ - public function getByContext($contextClass, $contextID, $options = []) - { - if (!$this->fieldExists('ContextClass')) { - throw new Exception('getByContext requires the field ContextClass to be defined'); - } - - $options = Util::prepareOptions($options, [ - 'conditions' => [], - 'order' => false, - ]); - - if (!$options['order']) { - $options['order'] = [ - $this->getPrimaryKeyName() => 'ASC', - ]; - } - - $options['conditions']['ContextClass'] = $contextClass; - $options['conditions']['ContextID'] = $contextID; - - $record = $this->getRecordByWhere($options['conditions'], $options); - - return $this->instantiateRecord($record); - } - - /** - * Get model object by configurable static::$handleField value - * - * @param int $id - * @return Model|null - */ - public function getByHandle($handle) - { - $handleField = $this->getHandleFieldName(); - - if ($this->fieldExists($handleField)) { - if ($Record = $this->getByField($handleField, $handle)) { - return $Record; - } - } - - if (!is_int($handle) && !(is_string($handle) && ctype_digit($handle))) { - return null; - } - - return $this->getByID($handle); - } - - /** - * Get model object by primary key. - * - * @param int $id - * @return Model|null - */ - public function getByID($id) - { - $record = $this->getRecordByField($this->getPrimaryKeyName(), $id, true); - return $this->instantiateRecord($record); - } - - /** - * Get model object by field. - * - * @param string $field Field name - * @param string $value Field value - * @param boolean $cacheIndex Optional. If we should cache the result or not. Default is false. - * @return Model|null - */ - public function getByField($field, $value, $cacheIndex = false) - { - $record = $this->getRecordByField($field, $value, $cacheIndex); - - return $this->instantiateRecord($record); - } - - /** - * Get record by field. - * - * @param string $field Field name - * @param string $value Field value - * @param boolean $cacheIndex Optional. If we should cache the result or not. Default is false. - * @return array|null First database result. - */ - public function getRecordByField($field, $value, $cacheIndex = false) - { - return $this->getRecordByWhere([$this->getColumnName($field) => $this->storage->escape($value)], $cacheIndex); - } - - /** - * Get the first result instantiated as a model from a simple select query with a where clause you can provide. - * - * @param array|string $conditions If passed as a string a database Where clause. If an array of field/value pairs will convert to a series of `field`='value' conditions joined with an AND operator. - * @param array|string $options Only takes 'order' option. A raw database string that will be inserted into the OR clause of the query or an array of field/direction pairs. - * @return Model|null Single model instantiated from the first database result - */ - public function getByWhere($conditions, $options = []) - { - $record = $this->getRecordByWhere($conditions, $options); - - return $this->instantiateRecord($record); - } - - /** - * Get the first result as an array from a simple select query with a where clause you can provide. - * - * @param array|string $conditions If passed as a string a database Where clause. If an array of field/value pairs will convert to a series of `field`='value' conditions joined with an AND operator. - * @param array|string $options Only takes 'order' option. A raw database string that will be inserted into the OR clause of the query or an array of field/direction pairs. - * @return array|null First database result. - */ - public function getRecordByWhere($conditions, $options = []) - { - if (!is_array($conditions)) { - $conditions = [$conditions]; - } - - $options = Util::prepareOptions($options, [ - 'order' => false, - ]); - - // initialize conditions and order - $conditions = $this->mapConditions($conditions); - $order = $options['order'] ? $this->mapFieldOrder($options['order']) : []; - - return $this->storage->oneRecord( - (new Select())->setTable($this->getTableName())->where(join(') AND (', $conditions))->order($order ? join(',', $order) : '')->limit('1'), - null, - $this->getHandleExceptionCallback() - ); - } - - /** - * Get the first result instantiated as a model from a simple select query you can provide. - * - * @param string $query Database query. The passed in string will be passed through vsprintf or sprintf with $params. - * @param array|string $params If an array will be passed through vsprintf as the second parameter with the query as the first. If a string will be used with sprintf instead. If nothing provided you must provide your own query. - * @return Model|null Single model instantiated from the first database result - */ - public function getByQuery($query, $params = []) - { - return $this->instantiateRecord($this->storage->oneRecord($query, $params, $this->getHandleExceptionCallback())); - } - - /** - * Get all models in the database by class name. This is a subclass utility method. Requires a Class field on the model. - * - * @param boolean $className The full name of the class including namespace. Optional. Will use the name of the current class if none provided. - * @param array $options - * @return array|null Array of instantiated ActiveRecord models returned from the database result. - */ - public function getAllByClass($className = false, $options = []) - { - return $this->getAllByField('Class', $className ? $className : $this->getModelClass(), $options); - } - - /** - * Get all models in the database by passing in an ActiveRecord model which has a 'ContextClass' field by the passed in records primary key. - * - * @param ActiveRecord $Record - * @param array $options - * @return array|null Array of instantiated ActiveRecord models returned from the database result. - */ - public function getAllByContextObject(ActiveRecord $Record, $options = []) - { - return $this->getAllByContext($Record::getRootClassName(), $Record->getPrimaryKeyValue(), $options); - } - - /** - * @param string $contextClass - * @param mixed $contextID - * @param array $options - * @return array|null Array of instantiated ActiveRecord models returned from the database result. - */ - public function getAllByContext($contextClass, $contextID, $options = []) - { - if (!$this->fieldExists('ContextClass')) { - throw new Exception('getByContext requires the field ContextClass to be defined'); - } - - $options = Util::prepareOptions($options, [ - 'conditions' => [], - ]); - - $options['conditions']['ContextClass'] = $contextClass; - $options['conditions']['ContextID'] = $contextID; - - return $this->instantiateRecords($this->getAllRecordsByWhere($options['conditions'], $options)); - } - - /** - * Get model objects by field and value. - * - * @param string $field Field name - * @param string $value Field value - * @param array $options - * @return array|null Array of models instantiated from the database result. - */ - public function getAllByField($field, $value, $options = []) - { - return $this->getAllByWhere([$field => $value], $options); - } - - /** - * Gets instantiated models as an array from a simple select query with a where clause you can provide. - * - * @param array|string $conditions If passed as a string a database Where clause. If an array of field/value pairs will convert to a series of `field`='value' conditions joined with an AND operator. - * @param array|string $options - * @return array|null Array of models instantiated from the database result. - */ - public function getAllByWhere($conditions = [], $options = []) - { - return $this->instantiateRecords($this->getAllRecordsByWhere($conditions, $options)); - } - - /** - * Attempts to get all database records for this class and return them as an array of instantiated models. - * - * @param array $options - * @return array|null - */ - public function getAll($options = []) - { - return $this->instantiateRecords($this->getAllRecords($options)); - } - - /** - * Attempts to get all database records for this class and returns them as is from the database. - * - * @param array $options - * @return array|null - */ - public function getAllRecords($options = []) - { - $options = Util::prepareOptions($options, [ - 'indexField' => false, - 'order' => false, - 'limit' => false, - 'calcFoundRows' => false, - 'offset' => 0, - ]); - - $select = (new Select())->setTable($this->getTableName())->calcFoundRows(); - - if ($options['order']) { - $select->order(join(',', $this->mapFieldOrder($options['order']))); - } - - if ($options['limit']) { - $select->limit(sprintf('%u,%u', $options['offset'], $options['limit'])); - } - if ($options['indexField']) { - return $this->storage->table($this->getColumnName($options['indexField']), $select, null, null, $this->getHandleExceptionCallback()); - } else { - return $this->storage->allRecords($select, null, $this->getHandleExceptionCallback()); - } - } - - /** - * Gets all records by a query you provide and then instantiates the results as an array of models. - * - * @param string $query Database query. The passed in string will be passed through vsprintf or sprintf with $params. - * @param array|string $params If an array will be passed through vsprintf as the second parameter with the query as the first. If a string will be used with sprintf instead. If nothing provided you must provide your own query. - * @return array|null Array of models instantiated from the first database result - */ - public function getAllByQuery($query, $params = []) - { - return $this->instantiateRecords($this->storage->allRecords($query, $params, $this->getHandleExceptionCallback())); - } - - /** - * Loops over the data returned from the raw query and writes a new array where the key uses the $keyField parameter instead. - * - * @param string $keyField - * @param string $query - * @param array $params - * @return array|null - */ - public function getTableByQuery($keyField, $query, $params = []) - { - return $this->instantiateRecords($this->storage->table($keyField, $query, $params, $this->getHandleExceptionCallback())); - } - - /** - * Gets database results as array from a simple select query with a where clause you can provide. - * - * @param array|string $conditions If passed as a string a database Where clause. If an array of field/value pairs will convert to a series of `field`='value' conditions joined with an AND operator. - * @param array|string $options - * @return array|null Array of records from the database result. - */ - public function getAllRecordsByWhere($conditions = [], $options = []) - { - $options = Util::prepareOptions($options, [ - 'indexField' => false, - 'order' => false, - 'limit' => false, - 'offset' => 0, - 'calcFoundRows' => !empty($options['limit']), - 'extraColumns' => false, - 'having' => false, - ]); - - // initialize conditions - if ($conditions) { - if (is_string($conditions)) { - $conditions = [$conditions]; - } - - $conditions = $this->mapConditions($conditions); - } - - $tableAlias = $this->getSelectTableAlias(); - $select = (new Select())->setTable($this->getTableName())->setTableAlias($tableAlias); - if ($options['calcFoundRows']) { - $select->calcFoundRows(); - } - - $expression = sprintf('`%s`.*', $tableAlias); - $select->expression($expression.$this->buildExtraColumns($options['extraColumns'])); - - $whereClause = $conditions ? join(') AND (', $conditions) : null; - - if ($conditions) { - $select->where($whereClause); - } - - if ($options['having']) { - $havingClause = $this->buildHaving($options['having'], $options['extraColumns']); - - if (Connections::getConnectionType() === \Divergence\IO\Database\PostgreSQL::class) { - $select->where($whereClause ? $whereClause . ' AND ' . trim($havingClause) : trim($havingClause)); - } else { - $select->having($havingClause); - } - } - - if ($options['order']) { - $select->order(join(',', $this->mapFieldOrder($options['order']))); - } - - if ($options['limit']) { - $select->limit(sprintf('%u,%u', $options['offset'], $options['limit'])); - } - - if ($options['indexField']) { - return $this->storage->table($this->getColumnName($options['indexField']), $select, null, null, $this->getHandleExceptionCallback()); - } else { - return $this->storage->allRecords($select, null, $this->getHandleExceptionCallback()); - } - } - - /** - * Generates a unique string based on the provided text making sure that nothing it returns already exists in the database for the given handleField option. If none is provided the static config $handleField will be used. - * - * @param string $text - * @param array $options - * @return string A unique handle. - */ - public function getUniqueHandle($text, $options = []) - { - // apply default options - $options = Util::prepareOptions($options, [ - 'handleField' => $this->getHandleFieldName(), - 'domainConstraints' => [], - 'alwaysSuffix' => false, - 'format' => '%s:%u', - ]); - - // transliterate accented characters - $text = iconv('UTF-8', 'ASCII//TRANSLIT', $text); - - // strip bad characters - $handle = $strippedText = preg_replace( - ['/\s+/', '/_*[^a-zA-Z0-9\-_:]+_*/', '/:[-_]/', '/^[-_]+/', '/[-_]+$/'], - ['_', '-', ':', '', ''], - trim($text) - ); - - $handle = trim($handle, '-_'); - - $incarnation = 0; - do { - // TODO: check for repeat posting here? - $incarnation++; - - if ($options['alwaysSuffix'] || $incarnation > 1) { - $handle = sprintf($options['format'], $strippedText, $incarnation); - } - } while ($this->getByWhere(array_merge($options['domainConstraints'], [$options['handleField']=>$handle]))); - - return $handle; - } - // TODO: make the handleField public function generateRandomHandle($length = 32) { + $className = $this->modelClass; + do { $handle = substr(md5(mt_rand(0, mt_getrandmax())), 0, $length); - } while ($this->getByField($this->getHandleFieldName(), $handle)); + } while ($this->getByField($className::$handleField, $handle)); return $handle; } - /** - * Builds the extra columns you might want to add to a database select query after the initial list of model fields. - * - * @param array|string $columns An array of keys and values or a string which will be added to a list of fields after the query's SELECT clause. - * @return string|null Extra columns to add after a SELECT clause in a query. Always starts with a comma. - */ - public function buildExtraColumns($columns) - { - if (!empty($columns)) { - if (is_array($columns)) { - foreach ($columns as $key => $value) { - return ', '.$value.' AS '.$key; - } - } else { - return ', ' . $columns; - } - } - } - - /** - * Builds the HAVING clause of a MySQL database query. - * - * @param array|string $having Same as conditions. Can provide a string to use or an array of field/value pairs which will be joined by the AND operator. - * @return string|null - */ - public function buildHaving($having, $extraColumns = null) - { - if (!empty($having)) { - $having = $this->replaceExtraColumnAliasesInHaving($having, $extraColumns); - - return ' (' . (is_array($having) ? join(') AND (', $this->mapConditions($having)) : $having) . ')'; - } - } - - protected function replaceExtraColumnAliasesInHaving($having, $extraColumns) - { - if (Connections::getConnectionType() !== \Divergence\IO\Database\PostgreSQL::class || empty($extraColumns)) { - return $having; - } - - $aliases = $this->extractExtraColumnAliases($extraColumns); - - if (empty($aliases)) { - return $having; - } - - $replaceAliases = function ($clause) use ($aliases) { - foreach ($aliases as $alias => $expression) { - $clause = str_replace( - ['`' . $alias . '`', '"' . $alias . '"', $alias], - ['(' . $expression . ')', '(' . $expression . ')', '(' . $expression . ')'], - $clause - ); - } - - return $clause; - }; - - if (is_array($having)) { - foreach ($having as $key => $clause) { - if (is_string($clause)) { - $having[$key] = $replaceAliases($clause); - } - } - - return $having; - } - - return is_string($having) ? $replaceAliases($having) : $having; - } - - protected function extractExtraColumnAliases($columns): array - { - $aliases = []; - $columns = is_array($columns) ? $columns : [$columns]; - - foreach ($columns as $key => $value) { - $column = is_string($key) ? $value . ' AS ' . $key : $value; - - if (!is_string($column)) { - continue; - } - - if (preg_match('/^(.*?)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/i', trim($column), $matches)) { - $aliases[$matches[2]] = $matches[1]; - } - } - - return $aliases; - } - - protected function getSelectTableAlias(): string - { - return 'Record'; - } - /** * Resolve the active database connection on demand. * diff --git a/src/Models/Factory/Getters/GetAll.php b/src/Models/Factory/Getters/GetAll.php new file mode 100644 index 0000000..6bff3cd --- /dev/null +++ b/src/Models/Factory/Getters/GetAll.php @@ -0,0 +1,11 @@ +instantiateRecords($this->factory->getAllRecords($options)); + } +} diff --git a/src/Models/Factory/Getters/GetAllByClass.php b/src/Models/Factory/Getters/GetAllByClass.php new file mode 100644 index 0000000..2b46e58 --- /dev/null +++ b/src/Models/Factory/Getters/GetAllByClass.php @@ -0,0 +1,11 @@ +factory->getAllByField('Class', $className ? $className : $this->getModelClass(), $options); + } +} diff --git a/src/Models/Factory/Getters/GetAllByContext.php b/src/Models/Factory/Getters/GetAllByContext.php new file mode 100644 index 0000000..e099062 --- /dev/null +++ b/src/Models/Factory/Getters/GetAllByContext.php @@ -0,0 +1,24 @@ +fieldExists('ContextClass')) { + throw new Exception('getByContext requires the field ContextClass to be defined'); + } + + $options = $this->prepareOptions($options, [ + 'conditions' => [], + ]); + + $options['conditions']['ContextClass'] = $contextClass; + $options['conditions']['ContextID'] = $contextID; + + return $this->instantiateRecords($this->factory->getAllRecordsByWhere($options['conditions'], $options)); + } +} diff --git a/src/Models/Factory/Getters/GetAllByContextObject.php b/src/Models/Factory/Getters/GetAllByContextObject.php new file mode 100644 index 0000000..45286f8 --- /dev/null +++ b/src/Models/Factory/Getters/GetAllByContextObject.php @@ -0,0 +1,13 @@ +factory->getAllByContext($Record::getRootClassName(), $Record->getPrimaryKeyValue(), $options); + } +} diff --git a/src/Models/Factory/Getters/GetAllByField.php b/src/Models/Factory/Getters/GetAllByField.php new file mode 100644 index 0000000..6b88349 --- /dev/null +++ b/src/Models/Factory/Getters/GetAllByField.php @@ -0,0 +1,11 @@ +factory->getAllByWhere([$field => $value], $options); + } +} diff --git a/src/Models/Factory/Getters/GetAllByQuery.php b/src/Models/Factory/Getters/GetAllByQuery.php new file mode 100644 index 0000000..27b498c --- /dev/null +++ b/src/Models/Factory/Getters/GetAllByQuery.php @@ -0,0 +1,11 @@ +instantiateRecords($this->getStorage()->allRecords($query, $params, $this->getHandleExceptionCallback())); + } +} diff --git a/src/Models/Factory/Getters/GetAllByWhere.php b/src/Models/Factory/Getters/GetAllByWhere.php new file mode 100644 index 0000000..cf58c11 --- /dev/null +++ b/src/Models/Factory/Getters/GetAllByWhere.php @@ -0,0 +1,11 @@ +instantiateRecords($this->factory->getAllRecordsByWhere($conditions, $options)); + } +} diff --git a/src/Models/Factory/Getters/GetAllRecords.php b/src/Models/Factory/Getters/GetAllRecords.php new file mode 100644 index 0000000..aeb1b01 --- /dev/null +++ b/src/Models/Factory/Getters/GetAllRecords.php @@ -0,0 +1,33 @@ +prepareOptions($options, [ + 'indexField' => false, + 'order' => false, + 'limit' => false, + 'calcFoundRows' => false, + 'offset' => 0, + ]); + + $select = $this->newSelect()->setTable($this->getTableName())->calcFoundRows(); + + if ($options['order']) { + $select->order(join(',', $this->mapFieldOrder($options['order']))); + } + + if ($options['limit']) { + $select->limit(sprintf('%u,%u', $options['offset'], $options['limit'])); + } + + if ($options['indexField']) { + return $this->getStorage()->table($this->getColumnName($options['indexField']), $select, null, null, $this->getHandleExceptionCallback()); + } + + return $this->getStorage()->allRecords($select, null, $this->getHandleExceptionCallback()); + } +} diff --git a/src/Models/Factory/Getters/GetAllRecordsByWhere.php b/src/Models/Factory/Getters/GetAllRecordsByWhere.php new file mode 100644 index 0000000..58d9bbe --- /dev/null +++ b/src/Models/Factory/Getters/GetAllRecordsByWhere.php @@ -0,0 +1,70 @@ +prepareOptions($options, [ + 'indexField' => false, + 'order' => false, + 'limit' => false, + 'offset' => 0, + 'calcFoundRows' => !empty($options['limit']), + 'extraColumns' => false, + 'having' => false, + ]); + + if ($conditions) { + if (is_string($conditions)) { + $conditions = [$conditions]; + } + + $conditions = $this->mapConditions($conditions); + } + + $tableAlias = $this->getSelectTableAlias(); + $select = $this->newSelect()->setTable($this->getTableName())->setTableAlias($tableAlias); + + if ($options['calcFoundRows']) { + $select->calcFoundRows(); + } + + $expression = sprintf('`%s`.*', $tableAlias); + $select->expression($expression.$this->buildExtraColumns($options['extraColumns'])); + + $whereClause = $conditions ? join(') AND (', $conditions) : null; + + if ($conditions) { + $select->where($whereClause); + } + + if ($options['having']) { + $havingClause = $this->buildHaving($options['having'], $options['extraColumns']); + + if (Connections::getConnectionType() === PostgreSQL::class) { + $select->where($whereClause ? $whereClause . ' AND ' . trim($havingClause) : trim($havingClause)); + } else { + $select->having($havingClause); + } + } + + if ($options['order']) { + $select->order(join(',', $this->mapFieldOrder($options['order']))); + } + + if ($options['limit']) { + $select->limit(sprintf('%u,%u', $options['offset'], $options['limit'])); + } + + if ($options['indexField']) { + return $this->getStorage()->table($this->getColumnName($options['indexField']), $select, null, null, $this->getHandleExceptionCallback()); + } + + return $this->getStorage()->allRecords($select, null, $this->getHandleExceptionCallback()); + } +} diff --git a/src/Models/Factory/Getters/GetByContext.php b/src/Models/Factory/Getters/GetByContext.php new file mode 100644 index 0000000..e873e87 --- /dev/null +++ b/src/Models/Factory/Getters/GetByContext.php @@ -0,0 +1,31 @@ +fieldExists('ContextClass')) { + throw new Exception('getByContext requires the field ContextClass to be defined'); + } + + $options = $this->prepareOptions($options, [ + 'conditions' => [], + 'order' => false, + ]); + + if (!$options['order']) { + $options['order'] = [ + $this->getPrimaryKeyName() => 'ASC', + ]; + } + + $options['conditions']['ContextClass'] = $contextClass; + $options['conditions']['ContextID'] = $contextID; + + return $this->instantiateRecord($this->factory->getRecordByWhere($options['conditions'], $options)); + } +} diff --git a/src/Models/Factory/Getters/GetByContextObject.php b/src/Models/Factory/Getters/GetByContextObject.php new file mode 100644 index 0000000..e0c6299 --- /dev/null +++ b/src/Models/Factory/Getters/GetByContextObject.php @@ -0,0 +1,13 @@ +factory->getByContext($Record::getRootClassName(), $Record->getPrimaryKeyValue(), $options); + } +} diff --git a/src/Models/Factory/Getters/GetByField.php b/src/Models/Factory/Getters/GetByField.php new file mode 100644 index 0000000..81e48b7 --- /dev/null +++ b/src/Models/Factory/Getters/GetByField.php @@ -0,0 +1,11 @@ +instantiateRecord($this->factory->getRecordByField($field, $value, $cacheIndex)); + } +} diff --git a/src/Models/Factory/Getters/GetByHandle.php b/src/Models/Factory/Getters/GetByHandle.php new file mode 100644 index 0000000..45ac984 --- /dev/null +++ b/src/Models/Factory/Getters/GetByHandle.php @@ -0,0 +1,23 @@ +getHandleFieldName(); + + if ($this->fieldExists($handleField)) { + if ($Record = $this->factory->getByField($handleField, $handle)) { + return $Record; + } + } + + if (!is_int($handle) && !(is_string($handle) && ctype_digit($handle))) { + return null; + } + + return $this->factory->getByID($handle); + } +} diff --git a/src/Models/Factory/Getters/GetByID.php b/src/Models/Factory/Getters/GetByID.php new file mode 100644 index 0000000..009a6c6 --- /dev/null +++ b/src/Models/Factory/Getters/GetByID.php @@ -0,0 +1,11 @@ +instantiateRecord($this->factory->getRecordByField($this->getPrimaryKeyName(), $id, true)); + } +} diff --git a/src/Models/Factory/Getters/GetByQuery.php b/src/Models/Factory/Getters/GetByQuery.php new file mode 100644 index 0000000..1abd27b --- /dev/null +++ b/src/Models/Factory/Getters/GetByQuery.php @@ -0,0 +1,11 @@ +instantiateRecord($this->getStorage()->oneRecord($query, $params, $this->getHandleExceptionCallback())); + } +} diff --git a/src/Models/Factory/Getters/GetByWhere.php b/src/Models/Factory/Getters/GetByWhere.php new file mode 100644 index 0000000..814c2ad --- /dev/null +++ b/src/Models/Factory/Getters/GetByWhere.php @@ -0,0 +1,11 @@ +instantiateRecord($this->factory->getRecordByWhere($conditions, $options)); + } +} diff --git a/src/Models/Factory/Getters/GetRecordByField.php b/src/Models/Factory/Getters/GetRecordByField.php new file mode 100644 index 0000000..a117d46 --- /dev/null +++ b/src/Models/Factory/Getters/GetRecordByField.php @@ -0,0 +1,11 @@ +factory->getRecordByWhere([$this->getColumnName($field) => $this->getStorage()->escape($value)], $cacheIndex); + } +} diff --git a/src/Models/Factory/Getters/GetRecordByWhere.php b/src/Models/Factory/Getters/GetRecordByWhere.php new file mode 100644 index 0000000..a8f66aa --- /dev/null +++ b/src/Models/Factory/Getters/GetRecordByWhere.php @@ -0,0 +1,30 @@ +prepareOptions($options, [ + 'order' => false, + ]); + + $conditions = $this->mapConditions($conditions); + $order = $options['order'] ? $this->mapFieldOrder($options['order']) : []; + + return $this->getStorage()->oneRecord( + $this->newSelect() + ->setTable($this->getTableName()) + ->where(join(') AND (', $conditions)) + ->order($order ? join(',', $order) : '') + ->limit('1'), + null, + $this->getHandleExceptionCallback() + ); + } +} diff --git a/src/Models/Factory/Getters/GetTableByQuery.php b/src/Models/Factory/Getters/GetTableByQuery.php new file mode 100644 index 0000000..260afcc --- /dev/null +++ b/src/Models/Factory/Getters/GetTableByQuery.php @@ -0,0 +1,11 @@ +instantiateRecords($this->getStorage()->table($keyField, $query, $params, $this->getHandleExceptionCallback())); + } +} diff --git a/src/Models/Factory/Getters/GetUniqueHandle.php b/src/Models/Factory/Getters/GetUniqueHandle.php new file mode 100644 index 0000000..b8fa013 --- /dev/null +++ b/src/Models/Factory/Getters/GetUniqueHandle.php @@ -0,0 +1,37 @@ +prepareOptions($options, [ + 'handleField' => $this->getHandleFieldName(), + 'domainConstraints' => [], + 'alwaysSuffix' => false, + 'format' => '%s:%u', + ]); + + $text = iconv('UTF-8', 'ASCII//TRANSLIT', $text); + + $handle = $strippedText = preg_replace( + ['/\s+/', '/_*[^a-zA-Z0-9\-_:]+_*/', '/:[-_]/', '/^[-_]+/', '/[-_]+$/'], + ['_', '-', ':', '', ''], + trim($text) + ); + + $handle = trim($handle, '-_'); + + $incarnation = 0; + do { + $incarnation++; + + if ($options['alwaysSuffix'] || $incarnation > 1) { + $handle = sprintf($options['format'], $strippedText, $incarnation); + } + } while ($this->factory->getByWhere(array_merge($options['domainConstraints'], [$options['handleField'] => $handle]))); + + return $handle; + } +} diff --git a/src/Models/Factory/Getters/ModelGetter.php b/src/Models/Factory/Getters/ModelGetter.php new file mode 100644 index 0000000..194c2c8 --- /dev/null +++ b/src/Models/Factory/Getters/ModelGetter.php @@ -0,0 +1,198 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Divergence\Models\Factory\Getters; + +use Divergence\Helpers\Util; +use Divergence\Models\Factory; +use Divergence\IO\Database\Connections; +use Divergence\IO\Database\PostgreSQL; +use Divergence\IO\Database\Query\Select; + +abstract class ModelGetter +{ + /** + * @var Factory + */ + protected $factory; + + public function __construct(Factory $factory) + { + $this->factory = $factory; + } + + protected function getModelClass(): string + { + return $this->factory->getModelClass(); + } + + protected function getStorage() + { + return $this->factory->getStorage(); + } + + protected function instantiateRecord($record) + { + return $this->factory->instantiateRecord($record); + } + + protected function instantiateRecords($records) + { + return $this->factory->instantiateRecords($records); + } + + protected function fieldExists(string $field): bool + { + $className = $this->getModelClass(); + + return $className::fieldExists($field); + } + + protected function getColumnName(string $field) + { + $className = $this->getModelClass(); + + return $className::getColumnName($field); + } + + protected function mapFieldOrder($order) + { + $className = $this->getModelClass(); + + return $className::mapFieldOrder($order); + } + + protected function mapConditions($conditions) + { + $className = $this->getModelClass(); + + return $className::mapConditions($conditions); + } + + protected function getHandleExceptionCallback(): array + { + return [$this->getModelClass(), 'handleException']; + } + + protected function getPrimaryKeyName(): string + { + $className = $this->getModelClass(); + + return $className::getPrimaryKey(); + } + + protected function getHandleFieldName(): string + { + $className = $this->getModelClass(); + + return $className::$handleField; + } + + protected function getTableName(): string + { + $className = $this->getModelClass(); + + return $className::$tableName; + } + + protected function getSelectTableAlias(): string + { + return 'Record'; + } + + protected function prepareOptions($options, array $defaults) + { + return Util::prepareOptions($options, $defaults); + } + + protected function newSelect(): Select + { + return new Select(); + } + + protected function buildExtraColumns($columns) + { + if (!empty($columns)) { + if (is_array($columns)) { + foreach ($columns as $key => $value) { + return ', '.$value.' AS '.$key; + } + } else { + return ', ' . $columns; + } + } + } + + protected function buildHaving($having, $extraColumns = null) + { + if (!empty($having)) { + $having = $this->replaceExtraColumnAliasesInHaving($having, $extraColumns); + + return ' (' . (is_array($having) ? join(') AND (', $this->mapConditions($having)) : $having) . ')'; + } + } + + protected function replaceExtraColumnAliasesInHaving($having, $extraColumns) + { + if (Connections::getConnectionType() !== PostgreSQL::class || empty($extraColumns)) { + return $having; + } + + $aliases = $this->extractExtraColumnAliases($extraColumns); + + if (empty($aliases)) { + return $having; + } + + $replaceAliases = function ($clause) use ($aliases) { + foreach ($aliases as $alias => $expression) { + $clause = str_replace( + ['`' . $alias . '`', '"' . $alias . '"', $alias], + ['(' . $expression . ')', '(' . $expression . ')', '(' . $expression . ')'], + $clause + ); + } + + return $clause; + }; + + if (is_array($having)) { + foreach ($having as $key => $clause) { + if (is_string($clause)) { + $having[$key] = $replaceAliases($clause); + } + } + + return $having; + } + + return is_string($having) ? $replaceAliases($having) : $having; + } + + protected function extractExtraColumnAliases($columns): array + { + $aliases = []; + $columns = is_array($columns) ? $columns : [$columns]; + + foreach ($columns as $key => $value) { + $column = is_string($key) ? $value . ' AS ' . $key : $value; + + if (!is_string($column)) { + continue; + } + + if (preg_match('/^(.*?)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/i', trim($column), $matches)) { + $aliases[$matches[2]] = $matches[1]; + } + } + + return $aliases; + } +} diff --git a/src/Models/Getters.php b/src/Models/Getters.php index f8ae7fb..427ecbf 100644 --- a/src/Models/Getters.php +++ b/src/Models/Getters.php @@ -10,7 +10,7 @@ namespace Divergence\Models; -use Divergence\Models\ActiveRecord; +use BadMethodCallException; /** * @property string $handleField Defined in the model @@ -19,296 +19,39 @@ */ trait Getters { - public static function Factory(?string $modelClass = null): Factory - { - return new Factory($modelClass ?: static::class); - } - /** - * Converts database record array to a model. Will attempt to use the record's Class field value to as the class to instantiate as or the name of this class if none is provided. - * - * @param array $record Database row as an array. - * @return static|null An instantiated ActiveRecord model from the provided data. + * @var array> */ - public static function instantiateRecord($record) - { - return static::Factory()->instantiateRecord($record); - } + protected static $_registeredGetterMethods = []; - /** - * Converts an array of database records to a model corresponding to each record. Will attempt to use the record's Class field value to as the class to instantiate as or the name of this class if none is provided. - * - * @param array $record An array of database rows. - * @return array|null An array of instantiated ActiveRecord models from the provided data. - */ - public static function instantiateRecords($records) - { - return static::Factory()->instantiateRecords($records); - } - - /** - * Uses ContextClass and ContextID to get an object. - * Quick way to attach things to other objects in a one-to-one relationship - * - * @param array $record An array of database rows. - * @return static|null An array of instantiated ActiveRecord models from the provided data. - */ - public static function getByContextObject(ActiveRecord $Record, $options = []) - { - return static::Factory()->getByContextObject($Record, $options); - } - - /** - * Same as getByContextObject but this method lets you specify the ContextClass manually. - * - * @param array $record An array of database rows. - * @return static|null An array of instantiated ActiveRecord models from the provided data. - */ - public static function getByContext($contextClass, $contextID, $options = []) - { - return static::Factory()->getByContext($contextClass, $contextID, $options); - } - - /** - * Get model object by configurable static::$handleField value - * - * @param int $id - * @return static|null - */ - public static function getByHandle($handle) - { - return static::Factory()->getByHandle($handle); - } - - /** - * Get model object by primary key. - * - * @param int $id - * @return static|null - */ - public static function getByID($id) - { - return static::Factory()->getByID($id); - } - - /** - * Get model object by field. - * - * @param string $field Field name - * @param string $value Field value - * @param boolean $cacheIndex Optional. If we should cache the result or not. Default is false. - * @return static|null - */ - public static function getByField($field, $value, $cacheIndex = false) - { - return static::Factory()->getByField($field, $value, $cacheIndex); - } - - /** - * Get record by field. - * - * @param string $field Field name - * @param string $value Field value - * @param boolean $cacheIndex Optional. If we should cache the result or not. Default is false. - * @return array|null First database result. - */ - public static function getRecordByField($field, $value, $cacheIndex = false) - { - return static::Factory()->getRecordByField($field, $value, $cacheIndex); - } - - /** - * Get the first result instantiated as a model from a simple select query with a where clause you can provide. - * - * @param array|string $conditions If passed as a string a database Where clause. If an array of field/value pairs will convert to a series of `field`='value' conditions joined with an AND operator. - * @param array|string $options Only takes 'order' option. A raw database string that will be inserted into the OR clause of the query or an array of field/direction pairs. - * @return static|null Single model instantiated from the first database result - */ - public static function getByWhere($conditions, $options = []) - { - return static::Factory()->getByWhere($conditions, $options); - } - - /** - * Get the first result as an array from a simple select query with a where clause you can provide. - * - * @param array|string $conditions If passed as a string a database Where clause. If an array of field/value pairs will convert to a series of `field`='value' conditions joined with an AND operator. - * @param array|string $options Only takes 'order' option. A raw database string that will be inserted into the OR clause of the query or an array of field/direction pairs. - * @return array|null First database result. - */ - public static function getRecordByWhere($conditions, $options = []) - { - return static::Factory()->getRecordByWhere($conditions, $options); - } - - /** - * Get the first result instantiated as a model from a simple select query you can provide. - * - * @param string $query Database query. The passed in string will be passed through vsprintf or sprintf with $params. - * @param array|string $params If an array will be passed through vsprintf as the second parameter with the query as the first. If a string will be used with sprintf instead. If nothing provided you must provide your own query. - * @return static|null Single model instantiated from the first database result - */ - public static function getByQuery($query, $params = []) - { - return static::Factory()->getByQuery($query, $params); - } - - /** - * Get all models in the database by class name. This is a subclass utility method. Requires a Class field on the model. - * - * @param boolean $className The full name of the class including namespace. Optional. Will use the name of the current class if none provided. - * @param array $options - * @return array|null Array of instantiated ActiveRecord models returned from the database result. - */ - public static function getAllByClass($className = false, $options = []) - { - return static::Factory()->getAllByClass($className, $options); - } - - /** - * Get all models in the database by passing in an ActiveRecord model which has a 'ContextClass' field by the passed in records primary key. - * - * @param ActiveRecord $Record - * @param array $options - * @return array|null Array of instantiated ActiveRecord models returned from the database result. - */ - public static function getAllByContextObject(ActiveRecord $Record, $options = []) - { - return static::Factory()->getAllByContextObject($Record, $options); - } - - /** - * @param string $contextClass - * @param mixed $contextID - * @param array $options - * @return array|null Array of instantiated ActiveRecord models returned from the database result. - */ - public static function getAllByContext($contextClass, $contextID, $options = []) - { - return static::Factory()->getAllByContext($contextClass, $contextID, $options); - } - - /** - * Get model objects by field and value. - * - * @param string $field Field name - * @param string $value Field value - * @param array $options - * @return array|null Array of models instantiated from the database result. - */ - public static function getAllByField($field, $value, $options = []) - { - return static::Factory()->getAllByField($field, $value, $options); - } - - /** - * Gets instantiated models as an array from a simple select query with a where clause you can provide. - * - * @param array|string $conditions If passed as a string a database Where clause. If an array of field/value pairs will convert to a series of `field`='value' conditions joined with an AND operator. - * @param array|string $options - * @return array|null Array of models instantiated from the database result. - */ - public static function getAllByWhere($conditions = [], $options = []) - { - return static::Factory()->getAllByWhere($conditions, $options); - } - - /** - * Attempts to get all database records for this class and return them as an array of instantiated models. - * - * @param array $options - * @return array|null - */ - public static function getAll($options = []) - { - return static::Factory()->getAll($options); - } - - /** - * Attempts to get all database records for this class and returns them as is from the database. - * - * @param array $options - * @return array|null - */ - public static function getAllRecords($options = []) - { - return static::Factory()->getAllRecords($options); - } - - /** - * Gets all records by a query you provide and then instantiates the results as an array of models. - * - * @param string $query Database query. The passed in string will be passed through vsprintf or sprintf with $params. - * @param array|string $params If an array will be passed through vsprintf as the second parameter with the query as the first. If a string will be used with sprintf instead. If nothing provided you must provide your own query. - * @return array|null Array of models instantiated from the first database result - */ - public static function getAllByQuery($query, $params = []) + public static function Factory(?string $modelClass = null): Factory { - return static::Factory()->getAllByQuery($query, $params); + return new Factory($modelClass ?: static::class); } - /** - * Loops over the data returned from the raw query and writes a new array where the key uses the $keyField parameter instead. - * - * @param string $keyField - * @param string $query - * @param array $params - * @return array|null - */ - public static function getTableByQuery($keyField, $query, $params = []) + protected static function registerGetterMethods(): void { - return static::Factory()->getTableByQuery($keyField, $query, $params); - } + $factory = static::Factory(); - /** - * Gets database results as array from a simple select query with a where clause you can provide. - * - * @param array|string $conditions If passed as a string a database Where clause. If an array of field/value pairs will convert to a series of `field`='value' conditions joined with an AND operator. - * @param array|string $options - * @return array|null Array of records from the database result. - */ - public static function getAllRecordsByWhere($conditions = [], $options = []) - { - return static::Factory()->getAllRecordsByWhere($conditions, $options); + static::$_registeredGetterMethods[static::class] = array_fill_keys( + array_map('strtolower', array_keys($factory->getGetterClasses())), + true + ); } - /** - * Generates a unique string based on the provided text making sure that nothing it returns already exists in the database for the given handleField option. If none is provided the static config $handleField will be used. - * - * @param string $text - * @param array $options - * @return string A unique handle. - */ - public static function getUniqueHandle($text, $options = []) + public static function __callStatic(string $name, array $arguments) { - return static::Factory()->getUniqueHandle($text, $options); - } + if (!isset(static::$_registeredGetterMethods[static::class])) { + static::registerGetterMethods(); + } - // TODO: make the handleField - public static function generateRandomHandle($length = 32) - { - return static::Factory()->generateRandomHandle($length); - } + $factory = static::Factory(); + $methodName = strtolower($name); - /** - * Builds the extra columns you might want to add to a database select query after the initial list of model fields. - * - * @param array|string $columns An array of keys and values or a string which will be added to a list of fields after the query's SELECT clause. - * @return string|null Extra columns to add after a SELECT clause in a query. Always starts with a comma. - */ - public static function buildExtraColumns($columns) - { - return static::Factory()->buildExtraColumns($columns); - } + if (isset(static::$_registeredGetterMethods[static::class][$methodName]) || method_exists($factory, $name)) { + return $factory->$name(...$arguments); + } - /** - * Builds the HAVING clause of a MySQL database query. - * - * @param array|string $having Same as conditions. Can provide a string to use or an array of field/value pairs which will be joined by the AND operator. - * @return string|null - */ - public static function buildHaving($having) - { - return static::Factory()->buildHaving($having); + throw new BadMethodCallException(sprintf('Call to undefined method %s::%s()', static::class, $name)); } } From 2f4af6518ee94ca76e2395f39836d0c253e16a62 Mon Sep 17 00:00:00 2001 From: Henry Paradiz Date: Sat, 28 Mar 2026 20:20:22 -0700 Subject: [PATCH 3/6] Broke up Records controller and Media controller into individual classes for each Endpoint --- .../Media/AbstractMediaEndpoint.php | 18 + src/Controllers/Media/Endpoints/Browse.php | 48 ++ src/Controllers/Media/Endpoints/Caption.php | 54 ++ src/Controllers/Media/Endpoints/Delete.php | 55 +++ src/Controllers/Media/Endpoints/Download.php | 49 ++ src/Controllers/Media/Endpoints/Info.php | 44 ++ src/Controllers/Media/Endpoints/Media.php | 79 +++ .../Media/Endpoints/MediaDelete.php | 52 ++ src/Controllers/Media/Endpoints/Thumbnail.php | 70 +++ src/Controllers/Media/Endpoints/Upload.php | 101 ++++ src/Controllers/MediaRequestHandler.php | 456 ++--------------- .../Records/AbstractRecordsEndpoint.php | 18 + src/Controllers/Records/Endpoints/Browse.php | 115 +++++ src/Controllers/Records/Endpoints/Create.php | 35 ++ src/Controllers/Records/Endpoints/Delete.php | 43 ++ src/Controllers/Records/Endpoints/Edit.php | 70 +++ .../Records/Endpoints/MultiDestroy.php | 86 ++++ .../Records/Endpoints/MultiSave.php | 98 ++++ src/Controllers/Records/Endpoints/Record.php | 47 ++ src/Controllers/RecordsRequestHandler.php | 466 +++--------------- src/Controllers/RequestHandler.php | 44 ++ 21 files changed, 1231 insertions(+), 817 deletions(-) create mode 100644 src/Controllers/Media/AbstractMediaEndpoint.php create mode 100644 src/Controllers/Media/Endpoints/Browse.php create mode 100644 src/Controllers/Media/Endpoints/Caption.php create mode 100644 src/Controllers/Media/Endpoints/Delete.php create mode 100644 src/Controllers/Media/Endpoints/Download.php create mode 100644 src/Controllers/Media/Endpoints/Info.php create mode 100644 src/Controllers/Media/Endpoints/Media.php create mode 100644 src/Controllers/Media/Endpoints/MediaDelete.php create mode 100644 src/Controllers/Media/Endpoints/Thumbnail.php create mode 100644 src/Controllers/Media/Endpoints/Upload.php create mode 100644 src/Controllers/Records/AbstractRecordsEndpoint.php create mode 100644 src/Controllers/Records/Endpoints/Browse.php create mode 100644 src/Controllers/Records/Endpoints/Create.php create mode 100644 src/Controllers/Records/Endpoints/Delete.php create mode 100644 src/Controllers/Records/Endpoints/Edit.php create mode 100644 src/Controllers/Records/Endpoints/MultiDestroy.php create mode 100644 src/Controllers/Records/Endpoints/MultiSave.php create mode 100644 src/Controllers/Records/Endpoints/Record.php diff --git a/src/Controllers/Media/AbstractMediaEndpoint.php b/src/Controllers/Media/AbstractMediaEndpoint.php new file mode 100644 index 0000000..843cc91 --- /dev/null +++ b/src/Controllers/Media/AbstractMediaEndpoint.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Divergence\Controllers\Media; + +use Psr\Http\Message\ResponseInterface; + +abstract class AbstractMediaEndpoint +{ + abstract public function handle(...$arguments): ResponseInterface; +} diff --git a/src/Controllers/Media/Endpoints/Browse.php b/src/Controllers/Media/Endpoints/Browse.php new file mode 100644 index 0000000..4e41040 --- /dev/null +++ b/src/Controllers/Media/Endpoints/Browse.php @@ -0,0 +1,48 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + [$options, $conditions, $responseID, $responseData] = array_pad($arguments, 4, null); + $conditions ??= []; + $responseData ??= []; + + if (!empty($_REQUEST['tag'])) { + if (!$Tag = Tag::getByHandle($_REQUEST['tag'])) { + return $this->handler->throwNotFoundError(); + } + + $conditions[] = 'ID IN (SELECT ContextID FROM tag_items WHERE TagID = '.$Tag->ID.' AND ContextClass = "Product")'; + } + + if (!empty($_REQUEST['ContextClass'])) { + $conditions['ContextClass'] = $_REQUEST['ContextClass']; + } + + if (!empty($_REQUEST['ContextID']) && is_numeric($_REQUEST['ContextID'])) { + $conditions['ContextID'] = $_REQUEST['ContextID']; + } + + return $this->parentBrowse($options, $conditions, $responseID, $responseData); + } + + protected function parentBrowse($options, $conditions, $responseID, $responseData): ResponseInterface + { + return $this->handler->__call('handleBrowseRequest', [$options, $conditions, $responseID, $responseData]); + } +} diff --git a/src/Controllers/Media/Endpoints/Caption.php b/src/Controllers/Media/Endpoints/Caption.php new file mode 100644 index 0000000..a789eef --- /dev/null +++ b/src/Controllers/Media/Endpoints/Caption.php @@ -0,0 +1,54 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + [$mediaId] = $arguments; + + $GLOBALS['Session']->requireAccountLevel('Staff'); + + if (empty($mediaId) || !is_numeric($mediaId)) { + return $this->handler->throwNotFoundError(); + } + + try { + $Media = Media::getById($mediaId); + } catch (Exception $e) { + return $this->handler->throwUnauthorizedError(); + } + + if (!$Media) { + return $this->handler->throwNotFoundError(); + } + + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + $Media->Caption = $_REQUEST['Caption']; + $Media->save(); + + return $this->handler->respond('mediaCaptioned', [ + 'success' => true, + 'data' => $Media, + ]); + } + + return $this->handler->respond('mediaCaption', [ + 'data' => $Media, + ]); + } +} diff --git a/src/Controllers/Media/Endpoints/Delete.php b/src/Controllers/Media/Endpoints/Delete.php new file mode 100644 index 0000000..c4d4c77 --- /dev/null +++ b/src/Controllers/Media/Endpoints/Delete.php @@ -0,0 +1,55 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + $GLOBALS['Session']->requireAccountLevel('Staff'); + + $mediaIds = []; + + if ($mediaID = $this->handler->peekPath()) { + $mediaIds = [$mediaID]; + } elseif (!empty($_REQUEST['mediaID'])) { + $mediaIds = [$_REQUEST['mediaID']]; + } elseif (isset($_REQUEST['media']) && is_array($_REQUEST['media'])) { + $mediaIds = $_REQUEST['media']; + } + + $deleted = []; + + foreach ($mediaIds as $mediaID) { + if (!is_numeric($mediaID)) { + continue; + } + + $Media = \Divergence\Models\Media\Media::getByID($mediaID); + + if (!$Media) { + return $this->handler->throwNotFoundError(); + } + + if ($Media->destroy()) { + $deleted[] = $Media; + } + } + + return $this->handler->respond('mediaDeleted', [ + 'success' => true, + 'data' => $deleted, + ]); + } +} diff --git a/src/Controllers/Media/Endpoints/Download.php b/src/Controllers/Media/Endpoints/Download.php new file mode 100644 index 0000000..ddb1fa5 --- /dev/null +++ b/src/Controllers/Media/Endpoints/Download.php @@ -0,0 +1,49 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + [$mediaId, $filename] = array_pad($arguments, 2, false); + + if (empty($mediaId) || !is_numeric($mediaId)) { + return $this->handler->throwNotFoundError(); + } + + try { + $Media = Media::getById($mediaId); + } catch (Exception $e) { + return $this->handler->throwUnauthorizedError(); + } + + if (!$Media) { + return $this->handler->throwNotFoundError(); + } + + if (!$this->handler->checkReadAccess($Media)) { + return $this->handler->throwUnauthorizedError(); + } + + $filePath = $Media->getFilesystemPath('original'); + $this->handler->responseBuilder = MediaBuilder::class; + $response = $this->handler->respondWithMedia($Media, 'original', $filePath); + + return $response->withHeader('Content-Disposition', 'attachment; filename="'.($filename ? $filename : $filePath).'"'); + } +} diff --git a/src/Controllers/Media/Endpoints/Info.php b/src/Controllers/Media/Endpoints/Info.php new file mode 100644 index 0000000..25bacc5 --- /dev/null +++ b/src/Controllers/Media/Endpoints/Info.php @@ -0,0 +1,44 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + [$mediaID] = $arguments; + + if (empty($mediaID) || !is_numeric($mediaID)) { + return $this->handler->throwNotFoundError(); + } + + try { + $Media = Media::getById($mediaID); + } catch (Exception $e) { + return $this->handler->throwUnauthorizedError(); + } + + if (!$Media) { + return $this->handler->throwNotFoundError(); + } + + if (!$this->handler->checkReadAccess($Media)) { + return $this->handler->throwUnauthorizedError(); + } + + return $this->handler->handleRecordRequest($Media); + } +} diff --git a/src/Controllers/Media/Endpoints/Media.php b/src/Controllers/Media/Endpoints/Media.php new file mode 100644 index 0000000..6fb0f68 --- /dev/null +++ b/src/Controllers/Media/Endpoints/Media.php @@ -0,0 +1,79 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + [$mediaID] = $arguments; + + if (empty($mediaID)) { + return $this->handler->throwNotFoundError(); + } + + try { + $Media = \Divergence\Models\Media\Media::getById($mediaID); + } catch (Exception $e) { + return $this->handler->throwUnauthorizedError(); + } + + if (!$Media) { + return $this->handler->throwNotFoundError(); + } + + if (!$this->handler->checkReadAccess($Media)) { + return $this->handler->throwNotFoundError(); + } + + $_server = $this->handler->getRequest()->getServerParams(); + + if (isset($_server['HTTP_ACCEPT']) && $_server['HTTP_ACCEPT'] == 'application/json') { + $this->handler->responseBuilder = JsonBuilder::class; + } + + if ($this->handler->responseBuilder == JsonBuilder::class) { + return $this->handler->respond('media', [ + 'success' => true, + 'data' => $Media, + ]); + } + + if ($variant = $this->handler->shiftPath()) { + if (!$Media->isVariantAvailable($variant)) { + return $this->handler->throwNotFoundError(); + } + } else { + $variant = 'original'; + } + + $this->handler->responseBuilder = MediaBuilder::class; + set_time_limit(0); + $filePath = $Media->getFilesystemPath($variant); + + if (!empty($_server['HTTP_IF_NONE_MATCH']) || !empty($_server['HTTP_IF_MODIFIED_SINCE'])) { + $this->handler->responseBuilder = EmptyBuilder::class; + $response = $this->handler->respondEmpty($filePath); + $response->withDefaults(304); + + return $response; + } + + return $this->handler->respondWithMedia($Media, $variant, $filePath); + } +} diff --git a/src/Controllers/Media/Endpoints/MediaDelete.php b/src/Controllers/Media/Endpoints/MediaDelete.php new file mode 100644 index 0000000..1db415b --- /dev/null +++ b/src/Controllers/Media/Endpoints/MediaDelete.php @@ -0,0 +1,52 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + if (empty($_REQUEST['media']) || !is_array($_REQUEST['media'])) { + return $this->handler->throwNotFoundError(); + } + + $mediaArray = []; + foreach ($_REQUEST['media'] as $mediaId) { + if (!is_numeric($mediaId)) { + return $this->handler->throwNotFoundError(); + } + + if ($Media = Media::getById($mediaId)) { + $mediaArray[$Media->ID] = $Media; + + if (!$this->handler->checkWriteAccess($Media)) { + return $this->handler->throwUnauthorizedError(); + } + } + } + + $deleted = []; + foreach ($mediaArray as $mediaId => $Media) { + if ($Media->delete()) { + $deleted[] = $mediaId; + } + } + + return $this->handler->respond('mediaDeleted', [ + 'success' => true, + 'deleted' => $deleted, + ]); + } +} diff --git a/src/Controllers/Media/Endpoints/Thumbnail.php b/src/Controllers/Media/Endpoints/Thumbnail.php new file mode 100644 index 0000000..85aaaf0 --- /dev/null +++ b/src/Controllers/Media/Endpoints/Thumbnail.php @@ -0,0 +1,70 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + [$Media] = array_pad($arguments, 1, null); + + if (!$Media) { + if (!$mediaID = $this->handler->shiftPath()) { + return $this->handler->throwNotFoundError(); + } elseif (!$Media = Media::getByID($mediaID)) { + return $this->handler->throwNotFoundError(); + } + } + + $_server = $this->handler->getRequest()->getServerParams(); + + if (!empty($_server['HTTP_IF_NONE_MATCH']) || !empty($_server['HTTP_IF_MODIFIED_SINCE'])) { + $this->handler->responseBuilder = EmptyBuilder::class; + $response = $this->handler->respondEmpty($Media->ID); + $response->withDefaults(304); + + return $response; + } + + if (preg_match('/^(\d+)x(\d+)(x([0-9A-F]{6})?)?$/i', $this->handler->peekPath(), $matches)) { + $this->handler->shiftPath(); + $maxWidth = $matches[1]; + $maxHeight = $matches[2]; + $fillColor = !empty($matches[4]) ? $matches[4] : false; + } else { + $maxWidth = $this->handler->defaultThumbnailWidth; + $maxHeight = $this->handler->defaultThumbnailHeight; + $fillColor = false; + } + + $cropped = false; + if ($this->handler->peekPath() == 'cropped') { + $this->handler->shiftPath(); + $cropped = true; + } + + try { + $thumbPath = $Media->getThumbnail($maxWidth, $maxHeight, $fillColor, $cropped); + $this->handler->responseBuilder = MediaBuilder::class; + + return $this->handler->respondWithThumbnail($Media, "$maxWidth-$maxHeight-$fillColor-$cropped", $thumbPath); + } catch (Exception $e) { + return $this->handler->throwNotFoundError(); + } + } +} diff --git a/src/Controllers/Media/Endpoints/Upload.php b/src/Controllers/Media/Endpoints/Upload.php new file mode 100644 index 0000000..f453034 --- /dev/null +++ b/src/Controllers/Media/Endpoints/Upload.php @@ -0,0 +1,101 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + [$options] = array_pad($arguments, 1, []); + + $this->handler->checkUploadAccess(); + + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + $options = array_merge([ + 'fieldName' => $this->handler->uploadFileFieldName, + ], $options); + + if (empty($_FILES[$options['fieldName']])) { + return $this->handler->throwUploadError('You did not select a file to upload'); + } + + if ($_FILES[$options['fieldName']]['error'] != UPLOAD_ERR_OK) { + switch ($_FILES[$options['fieldName']]['error']) { + case UPLOAD_ERR_NO_FILE: + return $this->handler->throwUploadError('You did not select a file to upload'); + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + return $this->handler->throwUploadError('Your file exceeds the maximum upload size. Please try again with a smaller file.'); + case UPLOAD_ERR_PARTIAL: + return $this->handler->throwUploadError('Your file was only partially uploaded, please try again.'); + default: + return $this->handler->throwUploadError('There was an unknown problem while processing your upload, please try again.'); + } + } + + if (!isset($options['Caption'])) { + if (!empty($_REQUEST['Caption'])) { + $options['Caption'] = $_REQUEST['Caption']; + } else { + $options['Caption'] = preg_replace('/\.[^.]+$/', '', $_FILES[$options['fieldName']]['name']); + } + } + + try { + $Media = Media::createFromUpload($_FILES[$options['fieldName']]['tmp_name'], $options); + } catch (Exception $e) { + return $this->handler->throwUploadError($e->getMessage()); + } + } elseif ($_SERVER['REQUEST_METHOD'] == 'PUT') { + $put = fopen($this->handler::$inputStream, 'r'); + $tmp = tempnam('/tmp', 'dvr'); + $fp = fopen($tmp, 'w'); + + while ($data = fread($put, 1024)) { + fwrite($fp, $data); + } + + fclose($fp); + fclose($put); + + try { + $Media = Media::createFromFile($tmp, $options); + } catch (Exception $e) { + return $this->handler->throwUploadError('The file you uploaded is not of a supported media format'); + } + } else { + return $this->handler->respond('upload'); + } + + if (!empty($_REQUEST['ContextClass']) && !empty($_REQUEST['ContextID'])) { + if (!is_subclass_of($_REQUEST['ContextClass'], ActiveRecord::class) + || !in_array($_REQUEST['ContextClass']::getStaticRootClass(), Media::$fields['ContextClass']['values']) + || !is_numeric($_REQUEST['ContextID'])) { + return $this->handler->throwUploadError('Context is invalid'); + } elseif (!$Media->Context = $_REQUEST['ContextClass']::getByID($_REQUEST['ContextID'])) { + return $this->handler->throwUploadError('Context class not found'); + } + + $Media->save(); + } + + return $this->handler->respond('uploadComplete', [ + 'success' => (bool) $Media, + 'data' => $Media, + ]); + } +} diff --git a/src/Controllers/MediaRequestHandler.php b/src/Controllers/MediaRequestHandler.php index ed1d494..3817fe2 100644 --- a/src/Controllers/MediaRequestHandler.php +++ b/src/Controllers/MediaRequestHandler.php @@ -10,7 +10,20 @@ namespace Divergence\Controllers; -use Exception; +use Divergence\Controllers\Media\Endpoints\Browse; +use Divergence\Controllers\Media\Endpoints\Caption; +use Divergence\Controllers\Media\Endpoints\Create; +use Divergence\Controllers\Media\Endpoints\Delete; +use Divergence\Controllers\Media\Endpoints\Download; +use Divergence\Controllers\Media\Endpoints\Edit; +use Divergence\Controllers\Media\Endpoints\Info; +use Divergence\Controllers\Media\Endpoints\Media as MediaEndpoint; +use Divergence\Controllers\Media\Endpoints\MediaDelete; +use Divergence\Controllers\Media\Endpoints\MultiDestroy; +use Divergence\Controllers\Media\Endpoints\MultiSave; +use Divergence\Controllers\Media\Endpoints\Record; +use Divergence\Controllers\Media\Endpoints\Thumbnail; +use Divergence\Controllers\Media\Endpoints\Upload; use Divergence\Models\Media\Media; use Divergence\Models\ActiveRecord; use Divergence\Responders\Response; @@ -68,6 +81,39 @@ class MediaRequestHandler extends RecordsRequestHandler private ?ServerRequest $request; + public function __construct() + { + parent::__construct(); + $this->registerMediaEndpointClasses(); + } + + protected function registerMediaEndpointClasses(): void + { + foreach ([ + Upload::class, + MediaEndpoint::class, + Info::class, + Download::class, + Caption::class, + Thumbnail::class, + [Browse::class, 'handleMediaBrowseRequest'], + MediaDelete::class, + ] as $registration) { + if (is_array($registration)) { + [$className, $endpointName] = $registration; + $this->registerEndpointClass($className, $endpointName); + continue; + } + + $this->registerEndpointClass($registration); + } + } + + public function getRequest(): ?ServerRequest + { + return $this->request; + } + public function handle(ServerRequestInterface $request): ResponseInterface { $this->request = $request; @@ -119,7 +165,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface case 'delete': { $mediaID = $this->shiftPath(); - return $this->handleDeleteRequest($mediaID); + return $this->handleMediaDeleteRequest($mediaID); } case 'thumbnail': @@ -135,7 +181,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface return $this->handleUploadRequest(); } - return $this->handleBrowseRequest(); + return $this->handleMediaBrowseRequest(); } default: @@ -150,99 +196,6 @@ public function handle(ServerRequestInterface $request): ResponseInterface } - public function handleUploadRequest($options = []): ResponseInterface - { - $this->checkUploadAccess(); - - if ($_SERVER['REQUEST_METHOD'] == 'POST') { - // init options - $options = array_merge([ - 'fieldName' => $this->uploadFileFieldName, - ], $options); - - - // check upload - if (empty($_FILES[$options['fieldName']])) { - return $this->throwUploadError('You did not select a file to upload'); - } - - // handle upload errors - if ($_FILES[$options['fieldName']]['error'] != UPLOAD_ERR_OK) { - switch ($_FILES[$options['fieldName']]['error']) { - case UPLOAD_ERR_NO_FILE: - return $this->throwUploadError('You did not select a file to upload'); - - case UPLOAD_ERR_INI_SIZE: - case UPLOAD_ERR_FORM_SIZE: - return $this->throwUploadError('Your file exceeds the maximum upload size. Please try again with a smaller file.'); - - case UPLOAD_ERR_PARTIAL: - return $this->throwUploadError('Your file was only partially uploaded, please try again.'); - - default: - return $this->throwUploadError('There was an unknown problem while processing your upload, please try again.'); - } - } - - // init caption - if (!isset($options['Caption'])) { - if (!empty($_REQUEST['Caption'])) { - $options['Caption'] = $_REQUEST['Caption']; - } else { - $options['Caption'] = preg_replace('/\.[^.]+$/', '', $_FILES[$options['fieldName']]['name']); - } - } - - // create media - try { - $Media = Media::createFromUpload($_FILES[$options['fieldName']]['tmp_name'], $options); - } catch (Exception $e) { - return $this->throwUploadError($e->getMessage()); - } - } elseif ($_SERVER['REQUEST_METHOD'] == 'PUT') { - $put = fopen(static::$inputStream, 'r'); // open input stream - - $tmp = tempnam('/tmp', 'dvr'); // use PHP to make a temporary file - $fp = fopen($tmp, 'w'); // open write stream to temp file - - // write - while ($data = fread($put, 1024)) { - fwrite($fp, $data); - } - - // close handles - fclose($fp); - fclose($put); - - // create media - try { - $Media = Media::createFromFile($tmp, $options); - } catch (Exception $e) { - return $this->throwUploadError('The file you uploaded is not of a supported media format'); - } - } else { - return $this->respond('upload'); - } - - // assign context - if (!empty($_REQUEST['ContextClass']) && !empty($_REQUEST['ContextID'])) { - if (!is_subclass_of($_REQUEST['ContextClass'], ActiveRecord::class) - || !in_array($_REQUEST['ContextClass']::getStaticRootClass(), Media::$fields['ContextClass']['values']) - || !is_numeric($_REQUEST['ContextID'])) { - return $this->throwUploadError('Context is invalid'); - } elseif (!$Media->Context = $_REQUEST['ContextClass']::getByID($_REQUEST['ContextID'])) { - return $this->throwUploadError('Context class not found'); - } - - $Media->save(); - } - - return $this->respond('uploadComplete', [ - 'success' => (bool)$Media - ,'data' => $Media, - ]); - } - public function respondRangeNotSatisfiable(string $responseID, int $start, int $end, int $size): Response { $this->responseBuilder = EmptyBuilder::class; @@ -376,317 +329,6 @@ public function respondEmpty($responseID, $responseData = []) } - public function handleMediaRequest($mediaID): ResponseInterface - { - if (empty($mediaID)) { - return $this->throwNotFoundError(); - } - - // get media - try { - $Media = Media::getById($mediaID); - } catch (Exception $e) { - return $this->throwUnauthorizedError(); - } - - if (!$Media) { - return $this->throwNotFoundError(); - } - - if (!$this->checkReadAccess($Media)) { - return $this->throwNotFoundError(); - } - - - $_server = $this->request->getServerParams(); - - if (isset($_server['HTTP_ACCEPT'])) { - if ($_server['HTTP_ACCEPT'] == 'application/json') { - $this->responseBuilder = JsonBuilder::class; - } - } - - if ($this->responseBuilder == JsonBuilder::class) { - return $this->respond('media', [ - 'success' => true - ,'data' => $Media, - ]); - } else { - - // determine variant - if ($variant = $this->shiftPath()) { - if (!$Media->isVariantAvailable($variant)) { - return $this->throwNotFoundError(); - } - } else { - $variant = 'original'; - } - - // initialize response - $this->responseBuilder = MediaBuilder::class; - set_time_limit(0); - $filePath = $Media->getFilesystemPath($variant); - - // media are immutable for a given URL, so no need to actually check anything if the browser wants to revalidate its cache - if (!empty($_server['HTTP_IF_NONE_MATCH']) || !empty($_server['HTTP_IF_MODIFIED_SINCE'])) { - $this->responseBuilder = EmptyBuilder::class; - $response = $this->respondEmpty($filePath); - $response->withDefaults(304); - - return $response; - } - - - return $this->respondWithMedia($Media, $variant, $filePath); - } - } - - public function handleInfoRequest($mediaID): ResponseInterface - { - if (empty($mediaID) || !is_numeric($mediaID)) { - $this->throwNotFoundError(); - } - - // get media - try { - $Media = Media::getById($mediaID); - } catch (Exception $e) { - return $this->throwUnauthorizedError(); - } - - if (!$Media) { - return $this->throwNotFoundError(); - } - - if (!$this->checkReadAccess($Media)) { - return $this->throwUnauthorizedError(); - } - - return parent::handleRecordRequest($Media); - } - - public function handleDownloadRequest($media_id, $filename = false): ResponseInterface - { - if (empty($media_id) || !is_numeric($media_id)) { - $this->throwNotFoundError(); - } - - // get media - try { - $Media = Media::getById($media_id); - } catch (Exception $e) { - return $this->throwUnauthorizedError(); - } - - - if (!$Media) { - return $this->throwNotFoundError(); - } - - if (!$this->checkReadAccess($Media)) { - return $this->throwUnauthorizedError(); - } - - $filePath = $Media->getFilesystemPath('original'); - - $this->responseBuilder = MediaBuilder::class; - $response = $this->respondWithMedia($Media, 'original', $filePath); - - $response = $response->withHeader('Content-Disposition', 'attachment; filename="'.($filename ? $filename : $filePath).'"'); - - return $response; - } - - public function handleCaptionRequest($media_id): ResponseInterface - { - // require authentication - $GLOBALS['Session']->requireAccountLevel('Staff'); - - if (empty($media_id) || !is_numeric($media_id)) { - return $this->throwNotFoundError(); - } - - // get media - try { - $Media = Media::getById($media_id); - } catch (Exception $e) { - return $this->throwUnauthorizedError(); - } - - - if (!$Media) { - $this->throwNotFoundError(); - } - - if ($_SERVER['REQUEST_METHOD'] == 'POST') { - $Media->Caption = $_REQUEST['Caption']; - $Media->save(); - - return $this->respond('mediaCaptioned', [ - 'success' => true - ,'data' => $Media, - ]); - } - - return $this->respond('mediaCaption', [ - 'data' => $Media, - ]); - } - - public function handleDeleteRequest(ActiveRecord $Record): ResponseInterface - { - // require authentication - $GLOBALS['Session']->requireAccountLevel('Staff'); - - if ($mediaID = $this->peekPath()) { - $mediaIDs = [$mediaID]; - } elseif (!empty($_REQUEST['mediaID'])) { - $mediaIDs = [$_REQUEST['mediaID']]; - } elseif (is_array($_REQUEST['media'])) { - $mediaIDs = $_REQUEST['media']; - } - - $deleted = []; - foreach ($mediaIDs as $mediaID) { - if (!is_numeric($mediaID)) { - continue; - } - - // get media - $Media = Media::getByID($mediaID); - - if (!$Media) { - return $this->throwNotFoundError(); - } - - if ($Media->destroy()) { - $deleted[] = $Media; - } - } - - return $this->respond('mediaDeleted', [ - 'success' => true - ,'data' => $deleted, - ]); - } - - public function handleThumbnailRequest(Media $Media = null): ResponseInterface - { - // get media - if (!$Media) { - if (!$mediaID = $this->shiftPath()) { - return $this->throwNotFoundError(); - } elseif (!$Media = Media::getByID($mediaID)) { - return $this->throwNotFoundError(); - } - } - - $_server = $this->request->getServerParams(); - - // thumbnails are immutable for a given URL, so no need to actually check anything if the browser wants to revalidate its cache - if (!empty($_server['HTTP_IF_NONE_MATCH']) || !empty($_server['HTTP_IF_MODIFIED_SINCE'])) { - $this->responseBuilder = EmptyBuilder::class; - $response = $this->respondEmpty($Media->ID); - $response->withDefaults(304); - - return $response; - } - - // get format - if (preg_match('/^(\d+)x(\d+)(x([0-9A-F]{6})?)?$/i', $this->peekPath(), $matches)) { - $this->shiftPath(); - $maxWidth = $matches[1]; - $maxHeight = $matches[2]; - $fillColor = !empty($matches[4]) ? $matches[4] : false; - } else { - $maxWidth = $this->defaultThumbnailWidth; - $maxHeight = $this->defaultThumbnailHeight; - $fillColor = false; - } - - if ($this->peekPath() == 'cropped') { - $this->shiftPath(); - $cropped = true; - } else { - $cropped = false; - } - - // get thumbnail media - try { - $thumbPath = $Media->getThumbnail($maxWidth, $maxHeight, $fillColor, $cropped); - - $this->responseBuilder = MediaBuilder::class; - $response = $this->respondWithThumbnail($Media, "$maxWidth-$maxHeight-$fillColor-$cropped", $thumbPath); - return $response; - } catch (Exception $e) { - return $this->throwNotFoundError(); - } - } - - - public function handleBrowseRequest($options = [], $conditions = [], $responseID = null, $responseData = []): ResponseInterface - { - // apply tag filter - if (!empty($_REQUEST['tag'])) { - // get tag - if (!$Tag = Tag::getByHandle($_REQUEST['tag'])) { - return $this->throwNotFoundError(); - } - - $conditions[] = 'ID IN (SELECT ContextID FROM tag_items WHERE TagID = '.$Tag->ID.' AND ContextClass = "Product")'; - } - - - // apply context filter - if (!empty($_REQUEST['ContextClass'])) { - $conditions['ContextClass'] = $_REQUEST['ContextClass']; - } - - if (!empty($_REQUEST['ContextID']) && is_numeric($_REQUEST['ContextID'])) { - $conditions['ContextID'] = $_REQUEST['ContextID']; - } - - return parent::handleBrowseRequest($options, $conditions, $responseID, $responseData); - } - - - - public function handleMediaDeleteRequest(): ResponseInterface - { - // sanity check - if (empty($_REQUEST['media']) || !is_array($_REQUEST['media'])) { - return $this->throwNotFoundError(); - } - - // retrieve photos - $media_array = []; - foreach ($_REQUEST['media'] as $media_id) { - if (!is_numeric($media_id)) { - return $this->throwNotFoundError(); - } - - if ($Media = Media::getById($media_id)) { - $media_array[$Media->ID] = $Media; - - if (!$this->checkWriteAccess($Media)) { - return $this->throwUnauthorizedError(); - } - } - } - - // delete - $deleted = []; - foreach ($media_array as $media_id => $Media) { - if ($Media->delete()) { - $deleted[] = $media_id; - } - } - - return $this->respond('mediaDeleted', [ - 'success' => true - ,'deleted' => $deleted, - ]); - } public function checkUploadAccess() { diff --git a/src/Controllers/Records/AbstractRecordsEndpoint.php b/src/Controllers/Records/AbstractRecordsEndpoint.php new file mode 100644 index 0000000..ba5f7a2 --- /dev/null +++ b/src/Controllers/Records/AbstractRecordsEndpoint.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Divergence\Controllers\Records; + +use Psr\Http\Message\ResponseInterface; + +abstract class AbstractRecordsEndpoint +{ + abstract public function handle(...$arguments): ResponseInterface; +} diff --git a/src/Controllers/Records/Endpoints/Browse.php b/src/Controllers/Records/Endpoints/Browse.php new file mode 100644 index 0000000..b76318d --- /dev/null +++ b/src/Controllers/Records/Endpoints/Browse.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Divergence\Controllers\Records\Endpoints; + +use Divergence\Controllers\Records\AbstractRecordsEndpoint; +use Divergence\Controllers\RecordsRequestHandler; +use Divergence\IO\Database\Connections; +use Psr\Http\Message\ResponseInterface; + +class Browse extends AbstractRecordsEndpoint +{ + protected RecordsRequestHandler $handler; + + public function __construct(RecordsRequestHandler $handler) + { + $this->handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + [$options, $conditions, $responseID, $responseData] = array_pad($arguments, 4, null); + $conditions ??= []; + $responseData ??= []; + + if (!$this->handler->checkBrowseAccess($arguments)) { + return $this->handler->throwUnauthorizedError(); + } + + $conditions = $this->prepareBrowseConditions($conditions); + $options = $this->prepareDefaultBrowseOptions(); + + if (!empty($_REQUEST['sort'])) { + $sort = json_decode($_REQUEST['sort'], true); + + if (!$sort || !is_array($sort)) { + return $this->handler->respond('error', [ + 'success' => false, + 'failed' => [ + 'errors' => 'Invalid sorter.', + ], + ]); + } + + foreach ($sort as $field) { + $options['order'][$field['property']] = $field['direction']; + } + } + + if (!empty($_REQUEST['filter'])) { + $filter = json_decode($_REQUEST['filter'], true); + + if (!$filter || !is_array($filter)) { + return $this->handler->respond('error', [ + 'success' => false, + 'failed' => [ + 'errors' => 'Invalid filter.', + ], + ]); + } + + foreach ($filter as $field) { + $conditions[$field['property']] = $field['value']; + } + } + + $className = $this->handler::$recordClass; + $storageClass = Connections::getConnectionType(); + + return $this->handler->respond( + $responseID ?: $this->handler->getTemplateName($className::getPluralNoun()), + array_merge($responseData, [ + 'success' => true, + 'data' => $className::getAllByWhere($conditions, $options), + 'conditions' => $conditions, + 'total' => $storageClass::foundRows(), + 'limit' => $options['limit'], + 'offset' => $options['offset'], + ]) + ); + } + + protected function prepareBrowseConditions($conditions = []) + { + if ($this->handler->browseConditions) { + if (!is_array($this->handler->browseConditions)) { + $this->handler->browseConditions = [$this->handler->browseConditions]; + } + + $conditions = array_merge($this->handler->browseConditions, $conditions); + } + + return $conditions; + } + + protected function prepareDefaultBrowseOptions(): array + { + if (!isset($_REQUEST['offset']) && isset($_REQUEST['start']) && is_numeric($_REQUEST['start'])) { + $_REQUEST['offset'] = $_REQUEST['start']; + } + + return [ + 'limit' => !empty($_REQUEST['limit']) && is_numeric($_REQUEST['limit']) ? $_REQUEST['limit'] : $this->handler->browseLimitDefault, + 'offset' => !empty($_REQUEST['offset']) && is_numeric($_REQUEST['offset']) ? $_REQUEST['offset'] : false, + 'order' => $this->handler->browseOrder, + ]; + } +} diff --git a/src/Controllers/Records/Endpoints/Create.php b/src/Controllers/Records/Endpoints/Create.php new file mode 100644 index 0000000..1524cd7 --- /dev/null +++ b/src/Controllers/Records/Endpoints/Create.php @@ -0,0 +1,35 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + [$Record] = array_pad($arguments, 1, null); + + $this->handler->calledClass = get_called_class(); + + if (!$Record) { + $className = $this->handler::$recordClass; + $defaultClass = $className::getDefaultClassName(); + $Record = new $defaultClass(); + } + + $this->handler->onRecordCreatedHook($Record, $_REQUEST); + + return $this->handler->handleEditRequest($Record); + } +} diff --git a/src/Controllers/Records/Endpoints/Delete.php b/src/Controllers/Records/Endpoints/Delete.php new file mode 100644 index 0000000..d575a32 --- /dev/null +++ b/src/Controllers/Records/Endpoints/Delete.php @@ -0,0 +1,43 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + [$Record] = $arguments; + $className = $this->handler::$recordClass; + + if (!$this->handler->checkWriteAccess($Record)) { + return $this->handler->throwUnauthorizedError(); + } + + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + $data = $Record->data; + $Record->destroy(); + $this->handler->onRecordDeletedHook($Record, $data); + + return $this->handler->respond($this->handler->getTemplateName($className::getSingularNoun()).'Deleted', [ + 'success' => true, + 'data' => $Record, + ]); + } + + return $this->handler->respond('confirm', [ + 'question' => 'Are you sure you want to delete this '.$className::getSingularNoun().'?', + 'data' => $Record, + ]); + } +} diff --git a/src/Controllers/Records/Endpoints/Edit.php b/src/Controllers/Records/Endpoints/Edit.php new file mode 100644 index 0000000..8b7c581 --- /dev/null +++ b/src/Controllers/Records/Endpoints/Edit.php @@ -0,0 +1,70 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + [$Record] = $arguments; + $className = $this->handler::$recordClass; + + if (!$this->handler->checkWriteAccess($Record)) { + return $this->handler->throwUnauthorizedError(); + } + + if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT'])) { + if ($this->handler->responseBuilder === \Divergence\Responders\JsonBuilder::class) { + $_REQUEST = JSON::getRequestData(); + if (isset($_REQUEST['data']) && is_array($_REQUEST['data'])) { + $_REQUEST = $_REQUEST['data']; + } + } + + $_REQUEST = $_REQUEST ?: $_POST; + + $this->handler->applyRecordDelta($Record, $_REQUEST); + $this->handler->onBeforeRecordValidatedHook($Record, $_REQUEST); + + if ($Record->validate()) { + $this->handler->onBeforeRecordSavedHook($Record, $_REQUEST); + + try { + $Record->save(); + } catch (Exception $e) { + return $this->handler->respond('Error', [ + 'success' => false, + 'failed' => [ + 'errors' => $e->getMessage(), + ], + ]); + } + + $this->handler->onRecordSavedHook($Record, $_REQUEST); + + return $this->handler->respond($this->handler->getTemplateName($className::getSingularNoun()).'Saved', [ + 'success' => true, + 'data' => $Record, + ]); + } + } + + return $this->handler->respond($this->handler->getTemplateName($className::getSingularNoun()).'Edit', [ + 'success' => false, + 'data' => $Record, + ]); + } +} diff --git a/src/Controllers/Records/Endpoints/MultiDestroy.php b/src/Controllers/Records/Endpoints/MultiDestroy.php new file mode 100644 index 0000000..0842c9b --- /dev/null +++ b/src/Controllers/Records/Endpoints/MultiDestroy.php @@ -0,0 +1,86 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + $className = $this->handler::$recordClass; + + $this->handler->prepareResponseModeJSON(['POST', 'PUT', 'DELETE']); + + if (!empty($_REQUEST['data']) && $className::fieldExists(key($_REQUEST['data']))) { + $_REQUEST['data'] = [$_REQUEST['data']]; + } + + if (empty($_REQUEST['data']) || !is_array($_REQUEST['data'])) { + return $this->handler->respond('error', [ + 'success' => false, + 'failed' => [ + 'errors' => 'Save expects "data" field as array of records.', + ], + ]); + } + + $results = []; + $failed = []; + + foreach ($_REQUEST['data'] as $datum) { + try { + $results[] = $this->processDatumDestroy($datum); + } catch (Exception $e) { + $failed[] = [ + 'record' => $datum, + 'errors' => $e->getMessage(), + ]; + } + } + + return $this->handler->respond($this->handler->getTemplateName($className::getPluralNoun()).'Destroyed', [ + 'success' => count($results) || !count($failed), + 'data' => $results, + 'failed' => $failed, + ]); + } + + protected function processDatumDestroy($datum) + { + $className = $this->handler::$recordClass; + $PrimaryKey = $className::getPrimaryKey(); + + if (is_numeric($datum)) { + $recordID = $datum; + } elseif (!empty($datum[$PrimaryKey]) && is_numeric($datum[$PrimaryKey])) { + $recordID = $datum[$PrimaryKey]; + } else { + throw new Exception($PrimaryKey.' missing'); + } + + if (!$Record = $className::getByField($PrimaryKey, $recordID)) { + throw new Exception($PrimaryKey.' not found'); + } + + if (!$this->handler->checkWriteAccess($Record)) { + throw new Exception('Write access denied'); + } + + if ($Record->destroy()) { + return $Record; + } + + throw new Exception('Destroy failed'); + } +} diff --git a/src/Controllers/Records/Endpoints/MultiSave.php b/src/Controllers/Records/Endpoints/MultiSave.php new file mode 100644 index 0000000..2f4f8d8 --- /dev/null +++ b/src/Controllers/Records/Endpoints/MultiSave.php @@ -0,0 +1,98 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + $className = $this->handler::$recordClass; + + $this->handler->prepareResponseModeJSON(['POST', 'PUT']); + + if (!empty($_REQUEST['data']) && $className::fieldExists(key($_REQUEST['data']))) { + $_REQUEST['data'] = [$_REQUEST['data']]; + } + + if (empty($_REQUEST['data']) || !is_array($_REQUEST['data'])) { + return $this->handler->respond('error', [ + 'success' => false, + 'failed' => [ + 'errors' => 'Save expects "data" field as array of records.', + ], + ]); + } + + $results = []; + $failed = []; + + foreach ($_REQUEST['data'] as $datum) { + try { + $results[] = $this->processDatumSave($datum); + } catch (Exception $e) { + $failed[] = [ + 'record' => $datum, + 'errors' => $e->getMessage(), + ]; + } + } + + return $this->handler->respond($this->handler->getTemplateName($className::getPluralNoun()).'Saved', [ + 'success' => count($results) || !count($failed), + 'data' => $results, + 'failed' => $failed, + ]); + } + + protected function getDatumRecord($datum) + { + $className = $this->handler::$recordClass; + $PrimaryKey = $className::getPrimaryKey(); + + if (empty($datum[$PrimaryKey])) { + $defaultClass = $className::getDefaultClassName(); + $record = new $defaultClass(); + $this->handler->onRecordCreatedHook($record, $datum); + + return $record; + } + + if (!$record = $className::getByID($datum[$PrimaryKey])) { + throw new Exception('Record not found'); + } + + return $record; + } + + protected function processDatumSave($datum) + { + $Record = $this->getDatumRecord($datum); + + if (!$this->handler->checkWriteAccess($Record)) { + throw new Exception('Write access denied'); + } + + $this->handler->applyRecordDelta($Record, $datum); + $this->handler->onBeforeRecordValidatedHook($Record, $datum); + $this->handler->onBeforeRecordSavedHook($Record, $datum); + + $Record->save(); + + $this->handler->onRecordSavedHook($Record, $datum); + + return (!$Record::fieldExists('Class') || get_class($Record) == $Record->Class) ? $Record : $Record->changeClass(); + } +} diff --git a/src/Controllers/Records/Endpoints/Record.php b/src/Controllers/Records/Endpoints/Record.php new file mode 100644 index 0000000..1bf8967 --- /dev/null +++ b/src/Controllers/Records/Endpoints/Record.php @@ -0,0 +1,47 @@ +handler = $handler; + } + + public function handle(...$arguments): ResponseInterface + { + [$Record, $action] = array_pad($arguments, 2, false); + + if (!$this->handler->checkReadAccess($Record)) { + return $this->handler->throwUnauthorizedError(); + } + + switch ($action ?: $action = $this->handler->shiftPath()) { + case '': + case false: + $className = $this->handler::$recordClass; + + return $this->handler->respond($this->handler->getTemplateName($className::getSingularNoun()), [ + 'success' => true, + 'data' => $Record, + ]); + + case 'edit': + return $this->handler->handleEditRequest($Record); + + case 'delete': + return $this->handler->handleDeleteRequest($Record); + + default: + return $this->handler->onRecordRequestNotHandled($Record, $action); + } + } +} diff --git a/src/Controllers/RecordsRequestHandler.php b/src/Controllers/RecordsRequestHandler.php index 06b7fd4..12e0bb1 100644 --- a/src/Controllers/RecordsRequestHandler.php +++ b/src/Controllers/RecordsRequestHandler.php @@ -10,7 +10,13 @@ namespace Divergence\Controllers; -use Exception; +use Divergence\Controllers\Records\Endpoints\Browse; +use Divergence\Controllers\Records\Endpoints\Create; +use Divergence\Controllers\Records\Endpoints\Delete; +use Divergence\Controllers\Records\Endpoints\Edit; +use Divergence\Controllers\Records\Endpoints\MultiDestroy; +use Divergence\Controllers\Records\Endpoints\MultiSave; +use Divergence\Controllers\Records\Endpoints\Record; use Divergence\Helpers\JSON; use Divergence\IO\Database\Connections; use Divergence\Responders\JsonBuilder; @@ -44,9 +50,28 @@ abstract class RecordsRequestHandler extends RequestHandler public function __construct() { + $this->registerEndpointClasses(); $this->responseBuilder = TwigBuilder::class; } + protected function registerEndpointClasses(): void + { + $this->endpointClasses = []; + $this->endpoints = []; + + foreach ([ + Browse::class, + Record::class, + MultiSave::class, + MultiDestroy::class, + Create::class, + Edit::class, + Delete::class, + ] as $className) { + $this->registerEndpointClass($className); + } + } + /** * Start of routing for this controller. * Methods in this execution path will always respond either as an error or a normal response. @@ -117,138 +142,6 @@ public function getRecordByHandle($handle) } } - public function prepareBrowseConditions($conditions = []) - { - if ($this->browseConditions) { - if (!is_array($this->browseConditions)) { - $this->browseConditions = [$this->browseConditions]; - } - $conditions = array_merge($this->browseConditions, $conditions); - } - return $conditions; - } - - public function prepareDefaultBrowseOptions() - { - if (!isset($_REQUEST['offset'])) { - if (isset($_REQUEST['start'])) { - if (is_numeric($_REQUEST['start'])) { - $_REQUEST['offset'] = $_REQUEST['start']; - } - } - } - - $limit = !empty($_REQUEST['limit']) && is_numeric($_REQUEST['limit']) ? $_REQUEST['limit'] : $this->browseLimitDefault; - $offset = !empty($_REQUEST['offset']) && is_numeric($_REQUEST['offset']) ? $_REQUEST['offset'] : false; - - $options = [ - 'limit' => $limit, - 'offset' => $offset, - 'order' => $this->browseOrder, - ]; - - return $options; - } - - public function handleBrowseRequest($options = [], $conditions = [], $responseID = null, $responseData = []): ResponseInterface - { - if (!$this->checkBrowseAccess(func_get_args())) { - return $this->throwUnauthorizedError(); - } - - $conditions = $this->prepareBrowseConditions($conditions); - - $options = $this->prepareDefaultBrowseOptions(); - - // process sorter - if (!empty($_REQUEST['sort'])) { - $sort = json_decode($_REQUEST['sort'], true); - if (!$sort || !is_array($sort)) { - return $this->respond('error', [ - 'success' => false, - 'failed' => [ - 'errors' => 'Invalid sorter.', - ], - ]); - } - - if (is_array($sort)) { - foreach ($sort as $field) { - $options['order'][$field['property']] = $field['direction']; - } - } - } - - // process filter - if (!empty($_REQUEST['filter'])) { - $filter = json_decode($_REQUEST['filter'], true); - if (!$filter || !is_array($filter)) { - return $this->respond('error', [ - 'success' => false, - 'failed' => [ - 'errors' => 'Invalid filter.', - ], - ]); - } - - foreach ($filter as $field) { - $conditions[$field['property']] = $field['value']; - } - } - - $className = static::$recordClass; - $storageClass = Connections::getConnectionType(); - - return $this->respond( - isset($responseID) ? $responseID : $this->getTemplateName($className::getPluralNoun()), - array_merge($responseData, [ - 'success' => true, - 'data' => $className::getAllByWhere($conditions, $options), - 'conditions' => $conditions, - 'total' => $storageClass::foundRows(), - 'limit' => $options['limit'], - 'offset' => $options['offset'], - ]) - ); - } - - - public function handleRecordRequest(ActiveRecord $Record, $action = false) - { - if (!$this->checkReadAccess($Record)) { - return $this->throwUnauthorizedError(); - } - - switch ($action ? $action : $action = $this->shiftPath()) { - case '': - case false: - { - $className = static::$recordClass; - - return $this->respond($this->getTemplateName($className::getSingularNoun()), [ - 'success' => true, - 'data' => $Record, - ]); - } - - case 'edit': - { - return $this->handleEditRequest($Record); - } - - case 'delete': - { - return $this->handleDeleteRequest($Record); - } - - default: - { - return $this->onRecordRequestNotHandled($Record, $action); - } - } - } - - public function prepareResponseModeJSON($methods = []) { if ($this->responseBuilder === JsonBuilder::class && in_array($_SERVER['REQUEST_METHOD'], $methods)) { @@ -259,283 +152,6 @@ public function prepareResponseModeJSON($methods = []) } } - public function getDatumRecord($datum) - { - $className = static::$recordClass; - $PrimaryKey = $className::getPrimaryKey(); - if (empty($datum[$PrimaryKey])) { - $defaultClass = $className::getDefaultClassName(); - $record = new $defaultClass(); - $this->onRecordCreated($record, $datum); - } else { - if (!$record = $className::getByID($datum[$PrimaryKey])) { - throw new Exception('Record not found'); - } - } - return $record; - } - - public function processDatumSave($datum) - { - // get record - $Record = $this->getDatumRecord($datum); - - // check write access - if (!$this->checkWriteAccess($Record)) { - throw new Exception('Write access denied'); - } - - // apply delta - $this->applyRecordDelta($Record, $datum); - - // call template function - $this->onBeforeRecordValidated($Record, $datum); - - // try to save record - try { - // call template function - $this->onBeforeRecordSaved($Record, $datum); - - $Record->save(); - - // call template function - $this->onRecordSaved($Record, $datum); - - return (!$Record::fieldExists('Class') || get_class($Record) == $Record->Class) ? $Record : $Record->changeClass(); - } catch (Exception $e) { - throw $e; - } - } - - public function handleMultiSaveRequest(): ResponseInterface - { - $className = static::$recordClass; - - $this->prepareResponseModeJSON(['POST','PUT']); - - if (!empty($_REQUEST['data'])) { - if ($className::fieldExists(key($_REQUEST['data']))) { - $_REQUEST['data'] = [$_REQUEST['data']]; - } - } - - - if (empty($_REQUEST['data']) || !is_array($_REQUEST['data'])) { - return $this->respond('error', [ - 'success' => false, - 'failed' => [ - 'errors' => 'Save expects "data" field as array of records.', - ], - ]); - } - - $results = []; - $failed = []; - - foreach ($_REQUEST['data'] as $datum) { - try { - $results[] = $this->processDatumSave($datum); - } catch (Exception $e) { - $failed[] = [ - 'record' => $datum, - 'errors' => $e->getMessage(), - ]; - continue; - } - } - - - return $this->respond($this->getTemplateName($className::getPluralNoun()).'Saved', [ - 'success' => count($results) || !count($failed), - 'data' => $results, - 'failed' => $failed, - ]); - } - - public function processDatumDestroy($datum) - { - $className = static::$recordClass; - $PrimaryKey = $className::getPrimaryKey(); - - // get record - if (is_numeric($datum)) { - $recordID = $datum; - } elseif (!empty($datum[$PrimaryKey]) && is_numeric($datum[$PrimaryKey])) { - $recordID = $datum[$PrimaryKey]; - } else { - throw new Exception($PrimaryKey.' missing'); - } - - if (!$Record = $className::getByField($PrimaryKey, $recordID)) { - throw new Exception($PrimaryKey.' not found'); - } - - // check write access - if (!$this->checkWriteAccess($Record)) { - throw new Exception('Write access denied'); - } - - if ($Record->destroy()) { - return $Record; - } else { - throw new Exception('Destroy failed'); - } - } - - public function handleMultiDestroyRequest(): ResponseInterface - { - $className = static::$recordClass; - - $this->prepareResponseModeJSON(['POST','PUT','DELETE']); - - if (!empty($_REQUEST['data'])) { - if ($className::fieldExists(key($_REQUEST['data']))) { - $_REQUEST['data'] = [$_REQUEST['data']]; - } - } - - if (empty($_REQUEST['data']) || !is_array($_REQUEST['data'])) { - return $this->respond('error', [ - 'success' => false, - 'failed' => [ - 'errors' => 'Save expects "data" field as array of records.', - ], - ]); - } - - $results = []; - $failed = []; - - foreach ($_REQUEST['data'] as $datum) { - try { - $results[] = $this->processDatumDestroy($datum); - } catch (Exception $e) { - $failed[] = [ - 'record' => $datum, - 'errors' => $e->getMessage(), - ]; - continue; - } - } - - return $this->respond($this->getTemplateName($className::getPluralNoun()).'Destroyed', [ - 'success' => count($results) || !count($failed), - 'data' => $results, - 'failed' => $failed, - ]); - } - - - public function handleCreateRequest(ActiveRecord $Record = null): ResponseInterface - { - // save static class - $this->calledClass = get_called_class(); - - if (!$Record) { - $className = static::$recordClass; - $defaultClass = $className::getDefaultClassName(); - $Record = new $defaultClass(); - } - - // call template function - $this->onRecordCreated($Record, $_REQUEST); - - return $this->handleEditRequest($Record); - } - - public function handleEditRequest(ActiveRecord $Record): ResponseInterface - { - $className = static::$recordClass; - - if (!$this->checkWriteAccess($Record)) { - return $this->throwUnauthorizedError(); - } - - if (in_array($_SERVER['REQUEST_METHOD'], ['POST','PUT'])) { - if ($this->responseBuilder === JsonBuilder::class) { - $_REQUEST = JSON::getRequestData(); - if (isset($_REQUEST['data']) && is_array($_REQUEST['data'])) { - $_REQUEST = $_REQUEST['data']; - } - } - $_REQUEST = $_REQUEST ? $_REQUEST : $_POST; - - // apply delta - $this->applyRecordDelta($Record, $_REQUEST); - - // call template function - $this->onBeforeRecordValidated($Record, $_REQUEST); - - // validate - if ($Record->validate()) { - // call template function - $this->onBeforeRecordSaved($Record, $_REQUEST); - - try { - // save session - $Record->save(); - } catch (Exception $e) { - return $this->respond('Error', [ - 'success' => false, - 'failed' => [ - 'errors' => $e->getMessage(), - ], - ]); - } - - // call template function - $this->onRecordSaved($Record, $_REQUEST); - - // fire created response - $responseID = $this->getTemplateName($className::getSingularNoun()).'Saved'; - $responseData = [ - 'success' => true, - 'data' => $Record, - ]; - return $this->respond($responseID, $responseData); - } - - // fall through back to form if validation failed - } - - $responseID = $this->getTemplateName($className::getSingularNoun()).'Edit'; - $responseData = [ - 'success' => false, - 'data' => $Record, - ]; - - return $this->respond($responseID, $responseData); - } - - - public function handleDeleteRequest(ActiveRecord $Record): ResponseInterface - { - $className = static::$recordClass; - - if (!$this->checkWriteAccess($Record)) { - return $this->throwUnauthorizedError(); - } - - if ($_SERVER['REQUEST_METHOD'] == 'POST') { - $data = $Record->data; - $Record->destroy(); - - // call cleanup function after delete - $this->onRecordDeleted($Record, $data); - - // fire created response - return $this->respond($this->getTemplateName($className::getSingularNoun()).'Deleted', [ - 'success' => true, - 'data' => $Record, - ]); - } - - return $this->respond('confirm', [ - 'question' => 'Are you sure you want to delete this '.$className::getSingularNoun().'?', - 'data' => $Record, - ]); - } - // access control template functions public function checkBrowseAccess($arguments) { @@ -636,4 +252,34 @@ protected function throwRecordNotFoundError() { return $this->throwNotFoundError(); } + + public function onRecordCreatedHook(ActiveRecord $Record, $data): void + { + $this->onRecordCreated($Record, $data); + } + + public function onBeforeRecordValidatedHook(ActiveRecord $Record, $data): void + { + $this->onBeforeRecordValidated($Record, $data); + } + + public function onBeforeRecordSavedHook(ActiveRecord $Record, $data): void + { + $this->onBeforeRecordSaved($Record, $data); + } + + public function onRecordDeletedHook(ActiveRecord $Record, $data): void + { + $this->onRecordDeleted($Record, $data); + } + + public function onRecordSavedHook(ActiveRecord $Record, $data): void + { + $this->onRecordSaved($Record, $data); + } + + public function throwRecordNotFoundResponse() + { + return $this->throwRecordNotFoundError(); + } } diff --git a/src/Controllers/RequestHandler.php b/src/Controllers/RequestHandler.php index 215460e..27f75d4 100644 --- a/src/Controllers/RequestHandler.php +++ b/src/Controllers/RequestHandler.php @@ -12,12 +12,24 @@ use Divergence\App; use Divergence\Responders\Response; +use BadMethodCallException; +use Exception; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; abstract class RequestHandler implements RequestHandlerInterface { + /** + * @var array + */ + protected $endpointClasses = []; + + /** + * @var array + */ + protected $endpoints = []; + public string $responseBuilder; public function peekPath() @@ -41,5 +53,37 @@ public function respond($responseID, $responseData = []): ResponseInterface return new Response(new $className($responseID, $responseData)); } + protected function registerEndpointClass(string $className, ?string $endpointName = null): void + { + if ($endpointName === null) { + $parts = explode('\\', $className); + $endpointName = 'handle'.end($parts).'Request'; + } + + $endpointName = strtolower($endpointName); + + if (isset($this->endpointClasses[$endpointName])) { + throw new Exception(sprintf('Endpoint method collision for %s', $endpointName)); + } + + $this->endpointClasses[$endpointName] = $className; + } + + public function __call(string $name, array $arguments) + { + $endpointName = strtolower($name); + + if (!isset($this->endpointClasses[$endpointName])) { + throw new BadMethodCallException(sprintf('Call to undefined method %s::%s()', static::class, $name)); + } + + if (!isset($this->endpoints[$endpointName])) { + $endpointClass = $this->endpointClasses[$endpointName]; + $this->endpoints[$endpointName] = new $endpointClass($this); + } + + return $this->endpoints[$endpointName]->handle(...$arguments); + } + abstract public function handle(ServerRequestInterface $request): ResponseInterface; } From 8abcc0e1b2cc1b40732d7340666fb80dcbf795e8 Mon Sep 17 00:00:00 2001 From: Henry Paradiz Date: Sun, 29 Mar 2026 01:10:57 -0700 Subject: [PATCH 4/6] Lower complexity and update the readme. --- readme.md | 12 +- src/Controllers/Media/Endpoints/Upload.php | 224 ++++++++++++------ src/Controllers/Records/Endpoints/Record.php | 19 +- .../Factory/Getters/GetAllRecordsByWhere.php | 52 +++- 4 files changed, 222 insertions(+), 85 deletions(-) diff --git a/readme.md b/readme.md index f95e4cb..957193f 100644 --- a/readme.md +++ b/readme.md @@ -7,7 +7,6 @@ Divergence is a PHP framework designed for rapid development and modern practice ## [Documentation](https://github.com/Divergence/docs#divergence-framework-documentation) ## [Getting Started](https://github.com/Divergence/docs/blob/release/gettingstarted.md#getting-started) -## [V3 Architecture](docs/v3-architecture.md) ## Minimal Model @@ -33,7 +32,7 @@ class Article extends Model ## Purpose -This collection of classes contains my favorite building blocks for developing websites with PHP and they have an impressive track record with hundreds of currently active websites using one version or another of the classes in this framework. While they were originally written years ago they are all PSR compatible and support modern practices out of the box. +Divergence is a full-featured ActiveRecord framework built on a reflection-driven DTO-style backend. It is designed for performance, and it backs that up with benchmarks. It gives developers a fast procedural-global path for getting real work done, while its internal abstractions stay disciplined and modern. Divergence follows PSR-4, PSR-7, and PSR-15 wherever doing so strengthens the framework instead of turning it into ceremony. Unit testing the code base and providing code coverage is a primary goal of this project. @@ -44,7 +43,7 @@ Unit testing the code base and providing code coverage is a primary goal of this * Declare relationships with static arrays or PHP 8 attributes (`#[Relation(...)]`). * Built in support for relationships and object versioning. * Speed up prototyping and automate new deployments by automatically creating tables based on your models when none are found. - * Built in support for MySQL and SQLite. + * Built in support for MySQL, PostgreSQL, and SQLite. * Routing * Simpler, faster, tree based routing system. @@ -55,6 +54,7 @@ Unit testing the code base and providing code coverage is a primary goal of this * Pre-made REST API controllers allow you to build APIs rapidly. * 100% Unit test coverage for filters, sorters, and conditions. * Build HTTP APIs in minutes by extending `RecordsRequestHandler` and setting the one config variable: the name of your model class. + * `RecordsRequestHandler` and `MediaRequestHandler` route response-producing actions through focused endpoint classes instead of one large handler method pile. * Use a pre-made security trait with RecordsRequestHandler or extend it and write in your own permissions. * Standard permissions interface allows reuse of permission traits from one model to another. @@ -85,8 +85,14 @@ composer test # MySQL suite only composer test:mysql +# PostgreSQL suite only +composer test:pgsql + # SQLite in-memory suite only composer test:sqlite + +# Run merged coverage across MySQL, PostgreSQL, and SQLite +composer test:coverage ``` ### Contributing To Divergence diff --git a/src/Controllers/Media/Endpoints/Upload.php b/src/Controllers/Media/Endpoints/Upload.php index f453034..0b38ffb 100644 --- a/src/Controllers/Media/Endpoints/Upload.php +++ b/src/Controllers/Media/Endpoints/Upload.php @@ -23,79 +23,169 @@ public function handle(...$arguments): ResponseInterface [$options] = array_pad($arguments, 1, []); $this->handler->checkUploadAccess(); + $uploadResult = $this->handleUploadRequest($options); + if ($uploadResult instanceof ResponseInterface) { + return $uploadResult; + } + + $uploadResult = $this->attachContext($uploadResult); + + if ($uploadResult instanceof ResponseInterface) { + return $uploadResult; + } + + return $this->respondUploadComplete($uploadResult); + } + + protected function handleUploadRequest(array $options) + { if ($_SERVER['REQUEST_METHOD'] == 'POST') { - $options = array_merge([ - 'fieldName' => $this->handler->uploadFileFieldName, - ], $options); + return $this->handlePostUpload($options); + } - if (empty($_FILES[$options['fieldName']])) { - return $this->handler->throwUploadError('You did not select a file to upload'); - } - - if ($_FILES[$options['fieldName']]['error'] != UPLOAD_ERR_OK) { - switch ($_FILES[$options['fieldName']]['error']) { - case UPLOAD_ERR_NO_FILE: - return $this->handler->throwUploadError('You did not select a file to upload'); - case UPLOAD_ERR_INI_SIZE: - case UPLOAD_ERR_FORM_SIZE: - return $this->handler->throwUploadError('Your file exceeds the maximum upload size. Please try again with a smaller file.'); - case UPLOAD_ERR_PARTIAL: - return $this->handler->throwUploadError('Your file was only partially uploaded, please try again.'); - default: - return $this->handler->throwUploadError('There was an unknown problem while processing your upload, please try again.'); - } - } - - if (!isset($options['Caption'])) { - if (!empty($_REQUEST['Caption'])) { - $options['Caption'] = $_REQUEST['Caption']; - } else { - $options['Caption'] = preg_replace('/\.[^.]+$/', '', $_FILES[$options['fieldName']]['name']); - } - } - - try { - $Media = Media::createFromUpload($_FILES[$options['fieldName']]['tmp_name'], $options); - } catch (Exception $e) { - return $this->handler->throwUploadError($e->getMessage()); - } - } elseif ($_SERVER['REQUEST_METHOD'] == 'PUT') { - $put = fopen($this->handler::$inputStream, 'r'); - $tmp = tempnam('/tmp', 'dvr'); - $fp = fopen($tmp, 'w'); - - while ($data = fread($put, 1024)) { - fwrite($fp, $data); - } - - fclose($fp); - fclose($put); - - try { - $Media = Media::createFromFile($tmp, $options); - } catch (Exception $e) { - return $this->handler->throwUploadError('The file you uploaded is not of a supported media format'); - } - } else { - return $this->handler->respond('upload'); - } - - if (!empty($_REQUEST['ContextClass']) && !empty($_REQUEST['ContextID'])) { - if (!is_subclass_of($_REQUEST['ContextClass'], ActiveRecord::class) - || !in_array($_REQUEST['ContextClass']::getStaticRootClass(), Media::$fields['ContextClass']['values']) - || !is_numeric($_REQUEST['ContextID'])) { - return $this->handler->throwUploadError('Context is invalid'); - } elseif (!$Media->Context = $_REQUEST['ContextClass']::getByID($_REQUEST['ContextID'])) { - return $this->handler->throwUploadError('Context class not found'); - } - - $Media->save(); + if ($_SERVER['REQUEST_METHOD'] == 'PUT') { + return $this->handlePutUpload($options); } + return $this->handler->respond('upload'); + } + + protected function respondUploadComplete($uploadResult): ResponseInterface + { return $this->handler->respond('uploadComplete', [ - 'success' => (bool) $Media, - 'data' => $Media, + 'success' => (bool) $uploadResult, + 'data' => $uploadResult, ]); } + + protected function handlePostUpload(array $options) + { + $options = $this->preparePostOptions($options); + $uploadError = $this->validatePostUpload($options); + + if ($uploadError !== null) { + return $uploadError; + } + + $options = $this->populatePostCaption($options); + + try { + return Media::createFromUpload($_FILES[$options['fieldName']]['tmp_name'], $options); + } catch (Exception $e) { + return $this->handler->throwUploadError($e->getMessage()); + } + } + + protected function preparePostOptions(array $options): array + { + return array_merge([ + 'fieldName' => $this->handler->uploadFileFieldName, + ], $options); + } + + protected function validatePostUpload(array $options): ?ResponseInterface + { + if (empty($_FILES[$options['fieldName']])) { + return $this->handler->throwUploadError('You did not select a file to upload'); + } + + if ($_FILES[$options['fieldName']]['error'] == UPLOAD_ERR_OK) { + return null; + } + + switch ($_FILES[$options['fieldName']]['error']) { + case UPLOAD_ERR_NO_FILE: + return $this->handler->throwUploadError('You did not select a file to upload'); + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + return $this->handler->throwUploadError('Your file exceeds the maximum upload size. Please try again with a smaller file.'); + case UPLOAD_ERR_PARTIAL: + return $this->handler->throwUploadError('Your file was only partially uploaded, please try again.'); + default: + return $this->handler->throwUploadError('There was an unknown problem while processing your upload, please try again.'); + } + } + + protected function populatePostCaption(array $options): array + { + if (isset($options['Caption'])) { + return $options; + } + + if (!empty($_REQUEST['Caption'])) { + $options['Caption'] = $_REQUEST['Caption']; + return $options; + } + + $options['Caption'] = preg_replace('/\.[^.]+$/', '', $_FILES[$options['fieldName']]['name']); + + return $options; + } + + protected function handlePutUpload(array $options) + { + $tmp = $this->copyPutStreamToTemporaryFile(); + + try { + return Media::createFromFile($tmp, $options); + } catch (Exception $e) { + return $this->handler->throwUploadError('The file you uploaded is not of a supported media format'); + } + } + + protected function copyPutStreamToTemporaryFile(): string + { + $put = fopen($this->handler::$inputStream, 'r'); + $tmp = tempnam('/tmp', 'dvr'); + $fp = fopen($tmp, 'w'); + + while ($data = fread($put, 1024)) { + fwrite($fp, $data); + } + + fclose($fp); + fclose($put); + + return $tmp; + } + + protected function attachContext($Media) + { + if (!$Media || !$this->hasContextRequest()) { + return $Media; + } + + $contextError = $this->validateContextRequest(); + + if ($contextError !== null) { + return $contextError; + } + + if (!$Media->Context = $_REQUEST['ContextClass']::getByID($_REQUEST['ContextID'])) { + return $this->handler->throwUploadError('Context class not found'); + } + + $Media->save(); + + return $Media; + } + + protected function hasContextRequest(): bool + { + return !empty($_REQUEST['ContextClass']) && !empty($_REQUEST['ContextID']); + } + + protected function validateContextRequest(): ?ResponseInterface + { + if ( + !is_subclass_of($_REQUEST['ContextClass'], ActiveRecord::class) + || !in_array($_REQUEST['ContextClass']::getStaticRootClass(), Media::$fields['ContextClass']['values']) + || !is_numeric($_REQUEST['ContextID']) + ) { + return $this->handler->throwUploadError('Context is invalid'); + } + + return null; + } } diff --git a/src/Controllers/Records/Endpoints/Record.php b/src/Controllers/Records/Endpoints/Record.php index 1bf8967..7a74bcf 100644 --- a/src/Controllers/Records/Endpoints/Record.php +++ b/src/Controllers/Records/Endpoints/Record.php @@ -5,6 +5,7 @@ use Divergence\Controllers\Records\AbstractRecordsEndpoint; use Divergence\Controllers\RecordsRequestHandler; use Divergence\Models\ActiveRecord; +use Exception; use Psr\Http\Message\ResponseInterface; class Record extends AbstractRecordsEndpoint @@ -29,10 +30,20 @@ public function handle(...$arguments): ResponseInterface case false: $className = $this->handler::$recordClass; - return $this->handler->respond($this->handler->getTemplateName($className::getSingularNoun()), [ - 'success' => true, - 'data' => $Record, - ]); + try { + return $this->handler->respond($this->handler->getTemplateName($className::getSingularNoun()), [ + 'success' => true, + 'data' => $Record, + ]); + } catch (Exception $e) { + return $this->handler->respond('error', [ + 'success' => false, + 'failed' => [ + 'errors' => $e->getMessage(), + ], + 'data' => $Record, + ]); + } case 'edit': return $this->handler->handleEditRequest($Record); diff --git a/src/Models/Factory/Getters/GetAllRecordsByWhere.php b/src/Models/Factory/Getters/GetAllRecordsByWhere.php index 58d9bbe..5f05f3f 100644 --- a/src/Models/Factory/Getters/GetAllRecordsByWhere.php +++ b/src/Models/Factory/Getters/GetAllRecordsByWhere.php @@ -9,7 +9,16 @@ class GetAllRecordsByWhere extends ModelGetter { public function getAllRecordsByWhere($conditions = [], $options = []) { - $options = $this->prepareOptions($options, [ + $options = $this->prepareOptions($options); + $conditions = $this->prepareConditions($conditions); + $select = $this->buildSelect($conditions, $options); + + return $this->fetchResults($select, $options); + } + + protected function prepareOptions($options, array $defaults = []): array + { + return parent::prepareOptions($options, array_merge([ 'indexField' => false, 'order' => false, 'limit' => false, @@ -17,8 +26,11 @@ public function getAllRecordsByWhere($conditions = [], $options = []) 'calcFoundRows' => !empty($options['limit']), 'extraColumns' => false, 'having' => false, - ]); + ], $defaults)); + } + protected function prepareConditions($conditions) + { if ($conditions) { if (is_string($conditions)) { $conditions = [$conditions]; @@ -27,6 +39,11 @@ public function getAllRecordsByWhere($conditions = [], $options = []) $conditions = $this->mapConditions($conditions); } + return $conditions; + } + + protected function buildSelect($conditions, array $options) + { $tableAlias = $this->getSelectTableAlias(); $select = $this->newSelect()->setTable($this->getTableName())->setTableAlias($tableAlias); @@ -43,15 +60,7 @@ public function getAllRecordsByWhere($conditions = [], $options = []) $select->where($whereClause); } - if ($options['having']) { - $havingClause = $this->buildHaving($options['having'], $options['extraColumns']); - - if (Connections::getConnectionType() === PostgreSQL::class) { - $select->where($whereClause ? $whereClause . ' AND ' . trim($havingClause) : trim($havingClause)); - } else { - $select->having($havingClause); - } - } + $this->applyHaving($select, $whereClause, $options); if ($options['order']) { $select->order(join(',', $this->mapFieldOrder($options['order']))); @@ -61,6 +70,27 @@ public function getAllRecordsByWhere($conditions = [], $options = []) $select->limit(sprintf('%u,%u', $options['offset'], $options['limit'])); } + return $select; + } + + protected function applyHaving($select, ?string $whereClause, array $options): void + { + if (!$options['having']) { + return; + } + + $havingClause = $this->buildHaving($options['having'], $options['extraColumns']); + + if (Connections::getConnectionType() === PostgreSQL::class) { + $select->where($whereClause ? $whereClause . ' AND ' . trim($havingClause) : trim($havingClause)); + return; + } + + $select->having($havingClause); + } + + protected function fetchResults($select, array $options) + { if ($options['indexField']) { return $this->getStorage()->table($this->getColumnName($options['indexField']), $select, null, null, $this->getHandleExceptionCallback()); } From bc53ed5638306952046fd8b19c40d15764530be9 Mon Sep 17 00:00:00 2001 From: Henry Paradiz Date: Sun, 29 Mar 2026 02:16:34 -0700 Subject: [PATCH 5/6] Type hints and null hints support instead of notnull attribute --- src/Models/ActiveRecord.php | 19 +++++++++++++++++-- src/Models/Auth/Session.php | 14 +++++++------- src/Models/Media/Media.php | 24 ++++++++++++------------ src/Models/Model.php | 12 ++++++------ src/Models/Versioning.php | 4 ++-- 5 files changed, 44 insertions(+), 29 deletions(-) diff --git a/src/Models/ActiveRecord.php b/src/Models/ActiveRecord.php index 24de0c9..227a755 100644 --- a/src/Models/ActiveRecord.php +++ b/src/Models/ActiveRecord.php @@ -1304,7 +1304,10 @@ public static function _definedAttributeFields(): array foreach ($attributes as $attribute) { $attributeName = $attribute->getName(); if ($attributeName === Column::class) { - $fields[$property->getName()] = array_merge($attribute->getArguments(), ['attributeField'=>true]); + $fields[$property->getName()] = static::applyAttributeFieldNullability( + $property, + array_merge($attribute->getArguments(), ['attributeField' => true]) + ); } if ($attributeName === Relation::class) { @@ -1315,7 +1318,10 @@ public static function _definedAttributeFields(): array } else { // default if (!$isRelationship) { - $fields[$property->getName()] = ['attributeField' => true]; + $fields[$property->getName()] = static::applyAttributeFieldNullability( + $property, + ['attributeField' => true] + ); } } } @@ -1326,6 +1332,15 @@ public static function _definedAttributeFields(): array ]; } + protected static function applyAttributeFieldNullability(ReflectionProperty $property, array $field): array + { + if ($type = $property->getType()) { + $field['notnull'] = !$type->allowsNull(); + } + + return $field; + } + /** * Called after _defineFields to initialize and apply defaults to the fields property diff --git a/src/Models/Auth/Session.php b/src/Models/Auth/Session.php index e5bd9b3..4562c68 100644 --- a/src/Models/Auth/Session.php +++ b/src/Models/Auth/Session.php @@ -48,17 +48,17 @@ class Session extends Model ], ]; - #[Column(notnull: false, default:null)] - private $ContextClass; + #[Column(default:null)] + private ?string $ContextClass; - #[Column(type:'int', notnull: false, default:null)] - private $ContextID; + #[Column(type:'int', default:null)] + private ?int $ContextID; #[Column(length:32)] - private $Handle; + private string $Handle; - #[Column(type:'timestamp', notnull:false)] - private $LastRequest; + #[Column(type:'timestamp')] + private ?string $LastRequest; #[Column(type:'binary', length:16)] private $LastIP; diff --git a/src/Models/Media/Media.php b/src/Models/Media/Media.php index 09b24c8..aed7576 100644 --- a/src/Models/Media/Media.php +++ b/src/Models/Media/Media.php @@ -49,25 +49,25 @@ class Media extends Model public static $tableName = 'media'; - #[Column(notnull: false, default:null)] - private $ContextClass; + #[Column(default:null)] + private ?string $ContextClass; - #[Column(type:'int', notnull: false, default:null)] - private $ContextID; + #[Column(type:'int', default:null)] + private ?int $ContextID; private $MIMEType; - #[Column(type:'int', unsigned: true, notnull:false)] - private $Width; + #[Column(type:'int', unsigned: true)] + private ?int $Width; - #[Column(type:'int', unsigned: true, notnull:false)] - private $Height; + #[Column(type:'int', unsigned: true)] + private ?int $Height; - #[Column(type: 'decimal', notnull: false, precision: 12, scale: 6, default: 0)] - private $Duration; + #[Column(type: 'decimal', precision: 12, scale: 6, default: 0)] + private ?float $Duration; - #[Column(notnull:false)] - private $Caption; + #[Column] + private ?string $Caption; public static $relationships = [ diff --git a/src/Models/Model.php b/src/Models/Model.php index 897fe44..52bda82 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -24,14 +24,14 @@ class Model extends ActiveRecord use Getters; #[Column(type: "integer", primary:true, autoincrement:true, unsigned:true)] - private $ID; + private int $ID; - #[Column(type: "enum", notnull:true, values:[])] - private $Class; + #[Column(type: "enum", values:[])] + private string $Class; #[Column(type: "timestamp", default:'CURRENT_TIMESTAMP')] - private $Created; + private string $Created; - #[Column(type: "integer", notnull:false)] - private $CreatorID; + #[Column(type: "integer")] + private ?int $CreatorID; } diff --git a/src/Models/Versioning.php b/src/Models/Versioning.php index 36c6860..1feb960 100644 --- a/src/Models/Versioning.php +++ b/src/Models/Versioning.php @@ -33,8 +33,8 @@ trait Versioning { public $wasDirty = false; - #[Column(type: "integer", unsigned:true, notnull:false)] - private $RevisionID; + #[Column(type: "integer", unsigned:true)] + private ?int $RevisionID; public static $versioningRelationships = [ 'History' => [ From ac3acc84585012bfedb7eb0a490ecf48a13916e3 Mon Sep 17 00:00:00 2001 From: Henry Paradiz Date: Sun, 29 Mar 2026 02:27:48 -0700 Subject: [PATCH 6/6] PHPDoc pass --- src/Controllers/MediaRequestHandler.php | 10 ++++ src/Controllers/RecordsRequestHandler.php | 8 +++ src/Controllers/RequestHandler.php | 13 +++++ src/IO/Database/Connections.php | 9 ++-- src/IO/Database/Writer/AbstractSqlWriter.php | 55 ++++++++++++++++++++ src/Models/ActiveRecord.php | 36 +++++++++++++ src/Models/Factory.php | 24 +++++++++ src/Models/Factory/Getters/ModelGetter.php | 53 ++++++++++++++++++- src/Models/Factory/Instantiator.php | 23 ++++++-- src/Models/Factory/ModelMetadata.php | 15 ++++-- src/Models/Getters.php | 3 ++ src/Models/Model.php | 20 +++++++ src/Models/Relations.php | 39 ++++++++++++++ 13 files changed, 296 insertions(+), 12 deletions(-) diff --git a/src/Controllers/MediaRequestHandler.php b/src/Controllers/MediaRequestHandler.php index 3817fe2..2314162 100644 --- a/src/Controllers/MediaRequestHandler.php +++ b/src/Controllers/MediaRequestHandler.php @@ -36,6 +36,16 @@ use GuzzleHttp\Psr7\ServerRequest; use Psr\Http\Message\ServerRequestInterface; +/** + * @method ResponseInterface handleUploadRequest() + * @method ResponseInterface handleMediaRequest($mediaID) + * @method ResponseInterface handleInfoRequest($mediaID) + * @method ResponseInterface handleDownloadRequest($mediaID, $filename = null) + * @method ResponseInterface handleCaptionRequest($mediaID) + * @method ResponseInterface handleThumbnailRequest() + * @method ResponseInterface handleMediaBrowseRequest($options = [], $conditions = [], $responseID = null, $responseData = []) + * @method ResponseInterface handleMediaDeleteRequest($mediaID = null) + */ class MediaRequestHandler extends RecordsRequestHandler { // RecordRequestHandler configuration diff --git a/src/Controllers/RecordsRequestHandler.php b/src/Controllers/RecordsRequestHandler.php index 12e0bb1..3d7b875 100644 --- a/src/Controllers/RecordsRequestHandler.php +++ b/src/Controllers/RecordsRequestHandler.php @@ -31,6 +31,14 @@ * * @package Divergence * @author Henry Paradiz + * + * @method ResponseInterface handleBrowseRequest($options = [], $conditions = [], $responseID = null, $responseData = []) + * @method ResponseInterface handleRecordRequest(ActiveRecord $Record, $action = false) + * @method ResponseInterface handleMultiSaveRequest() + * @method ResponseInterface handleMultiDestroyRequest() + * @method ResponseInterface handleCreateRequest(ActiveRecord $Record = null) + * @method ResponseInterface handleEditRequest(ActiveRecord $Record) + * @method ResponseInterface handleDeleteRequest(ActiveRecord $Record) */ abstract class RecordsRequestHandler extends RequestHandler { diff --git a/src/Controllers/RequestHandler.php b/src/Controllers/RequestHandler.php index 27f75d4..ecda377 100644 --- a/src/Controllers/RequestHandler.php +++ b/src/Controllers/RequestHandler.php @@ -18,6 +18,9 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Base request handler with shared endpoint registration and dynamic endpoint dispatch. + */ abstract class RequestHandler implements RequestHandlerInterface { /** @@ -53,6 +56,11 @@ public function respond($responseID, $responseData = []): ResponseInterface return new Response(new $className($responseID, $responseData)); } + /** + * @param class-string $className + * @param string|null $endpointName + * @return void + */ protected function registerEndpointClass(string $className, ?string $endpointName = null): void { if ($endpointName === null) { @@ -69,6 +77,11 @@ protected function registerEndpointClass(string $className, ?string $endpointNam $this->endpointClasses[$endpointName] = $className; } + /** + * @param string $name + * @param array $arguments + * @return mixed + */ public function __call(string $name, array $arguments) { $endpointName = strtolower($name); diff --git a/src/IO/Database/Connections.php b/src/IO/Database/Connections.php index 15ca562..87df1fd 100644 --- a/src/IO/Database/Connections.php +++ b/src/IO/Database/Connections.php @@ -14,6 +14,9 @@ use Exception; use PDO; +/** + * Connection resolver and backend selector for framework database access. + */ class Connections { /** @@ -161,7 +164,7 @@ public static function getConnection($label = null) /** * Gets the concrete storage class for the current connection config. * - * @return string + * @return class-string */ public static function getConnectionType(): string { @@ -198,7 +201,7 @@ public static function getQueryClass(string $queryClass): string * Gets the concrete storage class for a specific connection label. * * @param string|null $label - * @return string + * @return class-string */ protected static function getConnectionTypeForLabel(?string $label): string { @@ -219,7 +222,7 @@ protected static function getConnectionTypeForLabel(?string $label): string /** * Gets the database config and sets it to static::$Config * - * @return array static::$Config + * @return array> static::$Config */ protected static function config() { diff --git a/src/IO/Database/Writer/AbstractSqlWriter.php b/src/IO/Database/Writer/AbstractSqlWriter.php index 5f288f1..6124515 100644 --- a/src/IO/Database/Writer/AbstractSqlWriter.php +++ b/src/IO/Database/Writer/AbstractSqlWriter.php @@ -4,8 +4,16 @@ abstract class AbstractSqlWriter { + /** + * @var array>> + */ protected static $aggregateFieldConfigs; + /** + * @param class-string $recordClass + * @param string|null $field + * @return array|array> + */ protected static function getAggregateFieldOptions($recordClass, $field = null) { if (!isset(static::$aggregateFieldConfigs[$recordClass])) { @@ -19,6 +27,11 @@ protected static function getAggregateFieldOptions($recordClass, $field = null) return static::$aggregateFieldConfigs[$recordClass]; } + /** + * @param class-string $recordClass + * @param callable(string, array): void $callback + * @return void + */ protected static function eachNonRevisionField(string $recordClass, callable $callback): void { foreach (static::getAggregateFieldOptions($recordClass) as $fieldId => $field) { @@ -30,6 +43,11 @@ protected static function eachNonRevisionField(string $recordClass, callable $ca } } + /** + * @param class-string $recordClass + * @param string $fieldName + * @return array + */ protected static function normalizeFieldOptions(string $recordClass, string $fieldName): array { $field = static::getAggregateFieldOptions($recordClass, $fieldName); @@ -51,6 +69,10 @@ protected static function normalizeFieldOptions(string $recordClass, string $fie return $field; } + /** + * @param array $field + * @return string + */ protected static function getVariableCharacterType(array $field): string { return sprintf( @@ -59,6 +81,11 @@ protected static function getVariableCharacterType(array $field): string ); } + /** + * @param array{values: array} $field + * @param string $quote + * @return string + */ protected static function quoteEnumValues(array $field, string $quote = '"'): string { $escapedValues = array_map([static::class, 'escape'], $field['values']); @@ -66,6 +93,11 @@ protected static function quoteEnumValues(array $field, string $quote = '"'): st return join($quote . ',' . $quote, $escapedValues); } + /** + * @param class-string $recordClass + * @param bool $historyVariant + * @return array> + */ protected static function getTranslatedIndexes(string $recordClass, bool $historyVariant): array { $indexes = $historyVariant ? [] : $recordClass::$indexes; @@ -79,21 +111,39 @@ protected static function getTranslatedIndexes(string $recordClass, bool $histor return $indexes; } + /** + * @param class-string $recordClass + * @return bool + */ protected static function hasContextFields(string $recordClass): bool { return $recordClass::fieldExists('ContextClass') && $recordClass::fieldExists('ContextID'); } + /** + * @param class-string $recordClass + * @param bool $historyVariant + * @return string + */ protected static function getTargetTableName(string $recordClass, bool $historyVariant): string { return $historyVariant ? $recordClass::getHistoryTable() : $recordClass::$tableName; } + /** + * @param class-string $recordClass + * @return bool + */ protected static function isVersionedRecord(string $recordClass): bool { return is_subclass_of($recordClass, 'VersionedRecord'); } + /** + * @param array $statements + * @param class-string $recordClass + * @return void + */ protected static function appendContextIndex(array &$statements, string $recordClass): void { if (static::hasContextFields($recordClass)) { @@ -101,6 +151,11 @@ protected static function appendContextIndex(array &$statements, string $recordC } } + /** + * @param class-string $recordClass + * @param bool $historyVariant + * @return array> + */ protected static function getStandardIndexes(string $recordClass, bool $historyVariant): array { return static::getTranslatedIndexes($recordClass, $historyVariant); diff --git a/src/Models/ActiveRecord.php b/src/Models/ActiveRecord.php index 227a755..b6ca021 100644 --- a/src/Models/ActiveRecord.php +++ b/src/Models/ActiveRecord.php @@ -1678,6 +1678,10 @@ protected function _setValueAndMarkDirty($field, $value, $fieldOptions) } } + /** + * @param array|null $fields + * @return array + */ protected function _prepareRecordValues(?array $fields = null) { $record = []; @@ -1724,6 +1728,10 @@ protected function _prepareRecordValues(?array $fields = null) return $record; } + /** + * @param array>|null $fieldConfigs + * @return array + */ public function preparePersistedSet(?array $fieldConfigs = null): array { $set = []; @@ -1793,6 +1801,11 @@ public function preparePersistedSet(?array $fieldConfigs = null): array return $set; } + /** + * @param array $recordValues + * @param array>|null $fieldConfigs + * @return array + */ protected static function _mapValuesToSet($recordValues, ?array $fieldConfigs = null) { $set = []; @@ -1822,22 +1835,41 @@ protected static function _mapValuesToSet($recordValues, ?array $fieldConfigs = return $set; } + /** + * @param array|null $fields + * @return array + */ public function preparePersistedRecordValues(?array $fields = null): array { return $this->_prepareRecordValues($fields); } + /** + * @param array $recordValues + * @param array>|null $fieldConfigs + * @return array + */ public static function mapPreparedValuesToSet(array $recordValues, ?array $fieldConfigs = null): array { return static::_mapValuesToSet($recordValues, $fieldConfigs); } + /** + * @param string $field + * @param mixed $value + * @return void + */ public function primeFieldForSave(string $field, $value): void { unset($this->_convertedValues[$field]); $this->setRecordValueAndSynchronizeField($field, static::_cn($field), $value); } + /** + * @param int|string $insertID + * @param bool $isIntegerPrimaryKey + * @return void + */ public function finalizeInsert($insertID, bool $isIntegerPrimaryKey = false): void { if ($isIntegerPrimaryKey) { @@ -1861,6 +1893,10 @@ public function finalizeSave(): void $this->_isDirty = false; } + /** + * @param array $set + * @return void + */ public function cachePreparedPersistedSet(array $set): void { $this->_preparedPersistedSet = $set; diff --git a/src/Models/Factory.php b/src/Models/Factory.php index fbcdda5..f5dc3c4 100644 --- a/src/Models/Factory.php +++ b/src/Models/Factory.php @@ -38,6 +38,30 @@ use Divergence\IO\Database\Connections; use PDO; +/** + * @template TModel of Model + * + * @method TModel|null getByContextObject(ActiveRecord $Record, $options = []) + * @method TModel|null getByContext($contextClass, $contextID, $options = []) + * @method TModel|null getByHandle($handle) + * @method TModel|null getByID($id) + * @method TModel|null getByField($field, $value, $cacheIndex = false) + * @method array|null getRecordByField($field, $value, $cacheIndex = false) + * @method TModel|null getByWhere($conditions, $options = []) + * @method array|null getRecordByWhere($conditions, $options = []) + * @method TModel|null getByQuery($query, $params = []) + * @method array getAllByClass($className = false, $options = []) + * @method array getAllByContextObject(ActiveRecord $Record, $options = []) + * @method array getAllByContext($contextClass, $contextID, $options = []) + * @method array getAllByField($field, $value, $options = []) + * @method array getAllByWhere($conditions = [], $options = []) + * @method array getAll($options = []) + * @method array>|array> getAllRecords($options = []) + * @method array getAllByQuery($query, $params = []) + * @method array getTableByQuery($keyField, $query, $params = []) + * @method array>|array> getAllRecordsByWhere($conditions = [], $options = []) + * @method string getUniqueHandle($text, $options = []) + */ class Factory { /** diff --git a/src/Models/Factory/Getters/ModelGetter.php b/src/Models/Factory/Getters/ModelGetter.php index 194c2c8..852b983 100644 --- a/src/Models/Factory/Getters/ModelGetter.php +++ b/src/Models/Factory/Getters/ModelGetter.php @@ -15,14 +15,21 @@ use Divergence\IO\Database\Connections; use Divergence\IO\Database\PostgreSQL; use Divergence\IO\Database\Query\Select; +use Divergence\Models\Model; +/** + * @template TModel of Model + */ abstract class ModelGetter { /** - * @var Factory + * @var Factory */ protected $factory; + /** + * @param Factory $factory + */ public function __construct(Factory $factory) { $this->factory = $factory; @@ -33,16 +40,27 @@ protected function getModelClass(): string return $this->factory->getModelClass(); } + /** + * @return object + */ protected function getStorage() { return $this->factory->getStorage(); } + /** + * @param array|null $record + * @return TModel|null + */ protected function instantiateRecord($record) { return $this->factory->instantiateRecord($record); } + /** + * @param array>|array> $records + * @return array|array + */ protected function instantiateRecords($records) { return $this->factory->instantiateRecords($records); @@ -55,13 +73,17 @@ protected function fieldExists(string $field): bool return $className::fieldExists($field); } - protected function getColumnName(string $field) + protected function getColumnName(string $field): string { $className = $this->getModelClass(); return $className::getColumnName($field); } + /** + * @param string|array|array $order + * @return array + */ protected function mapFieldOrder($order) { $className = $this->getModelClass(); @@ -69,6 +91,10 @@ protected function mapFieldOrder($order) return $className::mapFieldOrder($order); } + /** + * @param string|array|array $conditions + * @return array + */ protected function mapConditions($conditions) { $className = $this->getModelClass(); @@ -107,6 +133,11 @@ protected function getSelectTableAlias(): string return 'Record'; } + /** + * @param array|false|null $options + * @param array $defaults + * @return array + */ protected function prepareOptions($options, array $defaults) { return Util::prepareOptions($options, $defaults); @@ -117,6 +148,10 @@ protected function newSelect(): Select return new Select(); } + /** + * @param string|array|array|false|null $columns + * @return string|null + */ protected function buildExtraColumns($columns) { if (!empty($columns)) { @@ -130,6 +165,11 @@ protected function buildExtraColumns($columns) } } + /** + * @param string|array|array|false|null $having + * @param string|array|array|false|null $extraColumns + * @return string|null + */ protected function buildHaving($having, $extraColumns = null) { if (!empty($having)) { @@ -139,6 +179,11 @@ protected function buildHaving($having, $extraColumns = null) } } + /** + * @param string|array|array|false|null $having + * @param string|array|array|false|null $extraColumns + * @return string|array|array|false|null + */ protected function replaceExtraColumnAliasesInHaving($having, $extraColumns) { if (Connections::getConnectionType() !== PostgreSQL::class || empty($extraColumns)) { @@ -176,6 +221,10 @@ protected function replaceExtraColumnAliasesInHaving($having, $extraColumns) return is_string($having) ? $replaceAliases($having) : $having; } + /** + * @param string|array|array|false|null $columns + * @return array + */ protected function extractExtraColumnAliases($columns): array { $aliases = []; diff --git a/src/Models/Factory/Instantiator.php b/src/Models/Factory/Instantiator.php index 29decbd..4f769a4 100644 --- a/src/Models/Factory/Instantiator.php +++ b/src/Models/Factory/Instantiator.php @@ -11,7 +11,11 @@ namespace Divergence\Models\Factory; use ReflectionClass; +use Divergence\Models\Model; +/** + * @template TModel of Model + */ class Instantiator { /** @@ -32,6 +36,9 @@ class Instantiator /** * @param string $modelClass */ + /** + * @param ModelMetadata $metadata + */ public function __construct(ModelMetadata $metadata) { $this->metadata = $metadata; @@ -39,6 +46,10 @@ public function __construct(ModelMetadata $metadata) $this->prototypeRegistry = new PrototypeRegistry(); } + /** + * @param array $record + * @return class-string + */ protected function getRecordClass($record) { $className = $this->metadata->getModelClass(); @@ -57,8 +68,8 @@ protected function getRecordClass($record) } /** - * @param array $record - * @return \Divergence\Models\Model|null + * @param array|null $record + * @return TModel|null */ public function instantiateRecord($record) { @@ -66,8 +77,8 @@ public function instantiateRecord($record) } /** - * @param array $records - * @return array<\Divergence\Models\Model>|null + * @param array>|array> $records + * @return array|array */ public function instantiateRecords($records) { @@ -78,6 +89,10 @@ public function instantiateRecords($records) return $records; } + /** + * @param array|null $record + * @return TModel|null + */ protected function instantiateModel($record) { $className = $this->getRecordClass($record); diff --git a/src/Models/Factory/ModelMetadata.php b/src/Models/Factory/ModelMetadata.php index 6a14fe8..2acf4cd 100644 --- a/src/Models/Factory/ModelMetadata.php +++ b/src/Models/Factory/ModelMetadata.php @@ -23,7 +23,7 @@ class ModelMetadata protected $modelClass; /** - * @var array + * @var array> */ protected $classFields; @@ -63,7 +63,7 @@ class ModelMetadata protected $classColumnName; /** - * @var array + * @var array{0: class-string, 1: string} */ protected $handleExceptionCallback; @@ -73,7 +73,7 @@ class ModelMetadata protected $persistedFields = []; /** - * @var array + * @var array> */ protected $persistedFieldConfigs = []; @@ -143,6 +143,9 @@ public function getModelClass(): string return $this->modelClass; } + /** + * @return array> + */ public function getClassFields(): array { return $this->classFields; @@ -188,6 +191,9 @@ public function getClassColumnName(): ?string return $this->classColumnName; } + /** + * @return array{0: class-string, 1: string} + */ public function getHandleExceptionCallback(): array { return $this->handleExceptionCallback; @@ -218,6 +224,9 @@ public function getPersistedFields(): array return $this->persistedFields; } + /** + * @return array> + */ public function getPersistedFieldConfigs(): array { return $this->persistedFieldConfigs; diff --git a/src/Models/Getters.php b/src/Models/Getters.php index 427ecbf..ba65172 100644 --- a/src/Models/Getters.php +++ b/src/Models/Getters.php @@ -24,6 +24,9 @@ trait Getters */ protected static $_registeredGetterMethods = []; + /** + * @return Factory + */ public static function Factory(?string $modelClass = null): Factory { return new Factory($modelClass ?: static::class); diff --git a/src/Models/Model.php b/src/Models/Model.php index 52bda82..aa0dab4 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -18,6 +18,26 @@ * @author Henry Paradiz * * {@inheritDoc} + * @method static static|null getByContextObject(ActiveRecord $Record, $options = []) + * @method static static|null getByContext($contextClass, $contextID, $options = []) + * @method static static|null getByHandle($handle) + * @method static static|null getByID($id) + * @method static static|null getByField($field, $value, $cacheIndex = false) + * @method static array|null getRecordByField($field, $value, $cacheIndex = false) + * @method static static|null getByWhere($conditions, $options = []) + * @method static array|null getRecordByWhere($conditions, $options = []) + * @method static static|null getByQuery($query, $params = []) + * @method static array getAllByClass($className = false, $options = []) + * @method static array getAllByContextObject(ActiveRecord $Record, $options = []) + * @method static array getAllByContext($contextClass, $contextID, $options = []) + * @method static array getAllByField($field, $value, $options = []) + * @method static array getAllByWhere($conditions = [], $options = []) + * @method static array getAll($options = []) + * @method static array>|array> getAllRecords($options = []) + * @method static array getAllByQuery($query, $params = []) + * @method static array getTableByQuery($keyField, $query, $params = []) + * @method static array>|array> getAllRecordsByWhere($conditions = [], $options = []) + * @method static string getUniqueHandle($text, $options = []) */ class Model extends ActiveRecord { diff --git a/src/Models/Relations.php b/src/Models/Relations.php index 0b0010d..76b865e 100644 --- a/src/Models/Relations.php +++ b/src/Models/Relations.php @@ -25,8 +25,15 @@ */ trait Relations { + /** + * @var array + */ protected $_relatedObjects = []; + /** + * @param string $relationship + * @return bool + */ public static function _relationshipExists($relationship) { if (is_array(static::$_classRelationships[get_called_class()])) { @@ -38,6 +45,8 @@ public static function _relationshipExists($relationship) /** * Called when anything relationships related is used for the first time to define relationships before _initRelationships + * + * @return void */ protected static function _defineRelationships() { @@ -59,6 +68,8 @@ protected static function _defineRelationships() /** * Called after _defineRelationships to initialize and apply defaults to the relationships property * Must be idempotent as it may be applied multiple times up the inheritence chain + * + * @return void */ protected static function _initRelationships() { @@ -74,6 +85,11 @@ protected static function _initRelationships() } } + /** + * @param string $relationship + * @param array $options + * @return array + */ protected static function _prepareOneOne(string $relationship, array $options): array { $options['local'] = $options['local'] ?? $relationship . 'ID'; @@ -81,6 +97,11 @@ protected static function _prepareOneOne(string $relationship, array $options): return $options; } + /** + * @param string $classShortName + * @param array $options + * @return array + */ protected static function _prepareOneMany(string $classShortName, array $options): array { $options['local'] = $options['local'] ?? 'ID'; @@ -92,6 +113,10 @@ protected static function _prepareOneMany(string $classShortName, array $options return $options; } + /** + * @param array $options + * @return array + */ protected static function _prepareContextChildren($options): array { $options['local'] = $options['local'] ?? 'ID'; @@ -102,6 +127,10 @@ protected static function _prepareContextChildren($options): array return $options; } + /** + * @param array $options + * @return array + */ protected static function _prepareContextParent($options): array { $options['local'] = $options['local'] ?? 'ContextID'; @@ -111,6 +140,11 @@ protected static function _prepareContextParent($options): array return $options; } + /** + * @param string $classShortName + * @param array $options + * @return array + */ protected static function _prepareManyMany($classShortName, $options): array { if (empty($options['class'])) { @@ -132,6 +166,11 @@ protected static function _prepareManyMany($classShortName, $options): array } // TODO: Make relations getPrimaryKeyValue() instead of using ID all the time. + /** + * @param string $relationship + * @param array $options + * @return array + */ protected static function _initRelationship($relationship, $options) { $classShortName = basename(str_replace('\\', '/', static::getRootClassName()));