From 390426b38d5c18699b9d5b71c3f65216446d99ab Mon Sep 17 00:00:00 2001 From: Roman Parpalak Date: Sat, 10 Aug 2024 23:14:24 +0300 Subject: [PATCH] Implemented UserSettingStorage for AdminYard to store user settings in the database. --- _admin/db_update.php | 32 +++- _admin/install.php | 2 +- _include/common.php | 2 +- _include/src/Admin/AdminExtension.php | 15 +- .../Admin/Controller/CommentController.php | 2 +- .../src/Admin/DynamicConfigFormBuilder.php | 20 +-- _include/src/AdminYard/UserSettingStorage.php | 137 ++++++++++++++++++ .../Extensions/ExtensionManagerAdapter.php | 16 +- _include/src/Model/ArticleManager.php | 14 +- _include/src/Model/Installer.php | 54 +++++-- _include/src/Pdo/DbLayer.php | 29 +++- _include/src/Pdo/DbLayerPostgres.php | 37 +++++ _include/src/Pdo/DbLayerSqlite.php | 37 +++++ _tests/integration/DbLayerCest.php | 59 ++++++++ composer.lock | 8 +- 15 files changed, 411 insertions(+), 53 deletions(-) create mode 100644 _include/src/AdminYard/UserSettingStorage.php diff --git a/_admin/db_update.php b/_admin/db_update.php index 98a4478..314a94a 100644 --- a/_admin/db_update.php +++ b/_admin/db_update.php @@ -210,7 +210,7 @@ $s2_db->addIndex('articles', 'template_idx', ['template']); $s2_db->dropIndex('articles', 'parent_id_idx'); - $result = $s2_db->buildAndQuery([ + $result = $s2_db->buildAndQuery([ 'SELECT' => 'id', 'FROM' => 'users', ]); @@ -235,7 +235,7 @@ $s2_db->addForeignKey('article_tag', 'fk_article', ['article_id'], 'articles', ['id'], 'CASCADE'); $s2_db->addForeignKey('article_tag', 'fk_tag', ['tag_id'], 'tags', ['id'], 'CASCADE'); - $result = $s2_db->buildAndQuery([ + $result = $s2_db->buildAndQuery([ 'SELECT' => 'login', 'FROM' => 'users', ]); @@ -249,6 +249,34 @@ $s2_db->addIndex('users_online', 'challenge_idx', ['challenge'], true); } +if (S2_DB_REVISION < 21) { + $s2_db->createTable('user_settings', [ + 'FIELDS' => [ + 'user_id' => [ + 'datatype' => 'INT(10) UNSIGNED', + 'allow_null' => false + ], + 'name' => [ + 'datatype' => 'VARCHAR(191)', + 'allow_null' => false + ], + 'value' => [ + 'datatype' => 'TEXT', + 'allow_null' => false + ], + ], + 'PRIMARY KEY' => ['user_id', 'name'], + 'FOREIGN KEYS' => [ + 'fk_user' => [ + 'columns' => ['user_id'], + 'reference_table' => 'users', + 'reference_columns' => ['id'], + 'on_delete' => 'CASCADE', + ], + ] + ]); +} + $s2_db->buildAndQuery([ 'UPDATE' => 'config', 'SET' => 'value = \'' . S2_DB_LAST_REVISION . '\'', diff --git a/_admin/install.php b/_admin/install.php index 6528254..fd77af5 100644 --- a/_admin/install.php +++ b/_admin/install.php @@ -21,7 +21,7 @@ use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer; define('S2_VERSION', '2.0dev'); -define('S2_DB_REVISION', 20); +define('S2_DB_REVISION', 21); define('MIN_PHP_VERSION', '8.2.0'); define('S2_ROOT', '../'); diff --git a/_include/common.php b/_include/common.php index 613d845..e3f07a2 100644 --- a/_include/common.php +++ b/_include/common.php @@ -163,7 +163,7 @@ function collectParameters(): array ini_set('session.cookie_httponly', true); } -define('S2_DB_LAST_REVISION', 20); +define('S2_DB_LAST_REVISION', 21); if (S2_DB_REVISION < S2_DB_LAST_REVISION) { include __DIR__ . '/../_admin/db_update.php'; } diff --git a/_include/src/Admin/AdminExtension.php b/_include/src/Admin/AdminExtension.php index ce6be56..c5463bb 100644 --- a/_include/src/Admin/AdminExtension.php +++ b/_include/src/Admin/AdminExtension.php @@ -16,6 +16,7 @@ use S2\AdminYard\Form\FormControlFactoryInterface; use S2\AdminYard\Form\FormFactory; use S2\AdminYard\MenuGenerator; +use S2\AdminYard\SettingStorage\SettingStorageInterface; use S2\AdminYard\TemplateRenderer; use S2\AdminYard\Transformer\ViewTransformer; use S2\AdminYard\Translator; @@ -32,6 +33,7 @@ use S2\Cms\AdminYard\CustomTemplateRenderer; use S2\Cms\AdminYard\Form\CustomFormControlFactory; use S2\Cms\AdminYard\Signal; +use S2\Cms\AdminYard\UserSettingStorage; use S2\Cms\Config\DynamicConfigProvider; use S2\Cms\Extensions\ExtensionManager; use S2\Cms\Extensions\ExtensionManagerAdapter; @@ -139,6 +141,13 @@ public function buildContainer(Container $container): void ); }); + $container->set(SettingStorageInterface::class, function (Container $container) { + return new UserSettingStorage( + $container->get(PermissionChecker::class), + $container->get(DbLayer::class), + ); + }, [StatefulServiceInterface::class]); + $container->set(DynamicConfigFormBuilder::class, function (Container $container) { return new DynamicConfigFormBuilder( $container->get(PermissionChecker::class), @@ -147,6 +156,7 @@ public function buildContainer(Container $container): void $container->get(FormFactory::class), $container->get(TemplateRenderer::class), $container->get(RequestStack::class), + $container->get(SettingStorageInterface::class), $container->getParameter('root_dir'), ...$container->getByTag(DynamicConfigFormExtenderInterface::class), ); @@ -197,6 +207,7 @@ public function buildContainer(Container $container): void $container->get(Translator::class), $container->get(TemplateRenderer::class), $container->get(FormFactory::class), + $container->get(SettingStorageInterface::class), ); $adminPanel->setLogger($container->get(LoggerInterface::class)); return $adminPanel; @@ -250,7 +261,7 @@ public function buildContainer(Container $container): void $provider = $container->get(DynamicConfigProvider::class); return new ArticleManager( $container->get(DbLayer::class), - $container->get(RequestStack::class), + $container->get(SettingStorageInterface::class), $container->get(PermissionChecker::class), $provider->get('S2_ADMIN_NEW_POS') === '1', $provider->get('S2_USE_HIERARCHY') === '1', @@ -280,7 +291,7 @@ public function buildContainer(Container $container): void $container->get(ExtensionManager::class), $container->get(PermissionChecker::class), $container->get(Translator::class), - $container->get(RequestStack::class), + $container->get(SettingStorageInterface::class), $container->get(TemplateRenderer::class), ); }, [AdminConfigExtenderInterface::class]); diff --git a/_include/src/Admin/Controller/CommentController.php b/_include/src/Admin/Controller/CommentController.php index dbbaf2c..73db653 100644 --- a/_include/src/Admin/Controller/CommentController.php +++ b/_include/src/Admin/Controller/CommentController.php @@ -40,7 +40,7 @@ public function rejectAction(Request $request): Response } // Borrow CSRF token from delete action - if ($this->getDeleteCsrfToken($primaryKey->toArray(), $request) !== $csrfToken) { + if ($this->getDeleteCsrfToken($primaryKey->toArray()) !== $csrfToken) { return new JsonResponse(['errors' => [ $this->translator->trans('Unable to confirm security token. A likely cause for this is that some time passed between when you first entered the page and when you submitted the form. If that is the case and you would like to continue, submit the form again.') ]], Response::HTTP_UNPROCESSABLE_ENTITY); diff --git a/_include/src/Admin/DynamicConfigFormBuilder.php b/_include/src/Admin/DynamicConfigFormBuilder.php index 6cc8151..1770d16 100644 --- a/_include/src/Admin/DynamicConfigFormBuilder.php +++ b/_include/src/Admin/DynamicConfigFormBuilder.php @@ -14,6 +14,7 @@ use S2\AdminYard\Database\TypeTransformer; use S2\AdminYard\Form\FormFactory; use S2\AdminYard\Form\FormParams; +use S2\AdminYard\SettingStorage\SettingStorageInterface; use S2\AdminYard\TemplateRenderer; use S2\AdminYard\Validator\Length; use S2\AdminYard\Validator\Regex; @@ -65,14 +66,15 @@ class DynamicConfigFormBuilder private array $dynamicConfigFormExtenders; public function __construct( - private readonly PermissionChecker $permissionChecker, - private readonly TranslatorInterface $translator, - private readonly TypeTransformer $typeTransformer, - private readonly FormFactory $formFactory, - private readonly TemplateRenderer $templateRenderer, - private readonly RequestStack $requestStack, - private readonly string $rootDir, - DynamicConfigFormExtenderInterface ...$dynamicConfigFormExtenders + private readonly PermissionChecker $permissionChecker, + private readonly TranslatorInterface $translator, + private readonly TypeTransformer $typeTransformer, + private readonly FormFactory $formFactory, + private readonly TemplateRenderer $templateRenderer, + private readonly RequestStack $requestStack, + private readonly SettingStorageInterface $settingStorage, + private readonly string $rootDir, + DynamicConfigFormExtenderInterface ...$dynamicConfigFormExtenders ) { $this->dynamicConfigFormExtenders = $dynamicConfigFormExtenders; } @@ -112,7 +114,7 @@ public function transformConfigTable(string $entityName, array &$header, array & $form = $this->formFactory->createEntityForm(new FormParams( $entityName, [$valFieldName => $field], - $this->requestStack->getMainRequest(), + $this->settingStorage, 'patch', $row['primary_key'], )); diff --git a/_include/src/AdminYard/UserSettingStorage.php b/_include/src/AdminYard/UserSettingStorage.php new file mode 100644 index 0000000..f7f1444 --- /dev/null +++ b/_include/src/AdminYard/UserSettingStorage.php @@ -0,0 +1,137 @@ +ensureParamsAreLoaded(); + + return isset($this->params[$this->permissionChecker->getUserId()][$string]); + } + + /** + * @throws DbLayerException + */ + public function get(string $key): array|string|int|float|bool|null + { + $this->ensureParamsAreLoaded(); + + return $this->params[$this->permissionChecker->getUserId()][$key] ?? null; + } + + /** + * @throws DbLayerException + */ + public function set(string $key, array|string|int|float|bool|null $data): void + { + $this->ensureParamsAreLoaded(); + $userId = $this->permissionChecker->getUserId(); + if ($userId === null) { + throw new \RuntimeException('No authenticated user found.'); + } + + if (($this->params[$userId][$key] ?? null) === $data) { + return; + } + + $this->params[$userId][$key] = $data; + + try { + $this->dbLayer->buildAndQuery([ + 'UPSERT' => 'user_id, name, value', + 'INTO' => self::TABLE_NAME, + 'UNIQUE' => 'user_id, name', + 'VALUES' => ':user_id, :name, :value', + ], [ + 'user_id' => $userId, + 'name' => $key, + 'value' => json_encode($data, JSON_THROW_ON_ERROR), + ]); + } catch (\JsonException $e) { + throw new \LogicException('Failed to encode user settings.', 0, $e); + } + } + + /** + * @throws DbLayerException + */ + public function remove(string $key): void + { + $userId = $this->permissionChecker->getUserId(); + if ($userId === null) { + throw new \RuntimeException('No authenticated user found.'); + } + + $this->dbLayer->buildAndQuery([ + 'DELETE' => self::TABLE_NAME, + 'WHERE' => 'user_id = :user_id AND name = :name', + ], [ + 'user_id' => $userId, + 'name' => $key, + ]); + } + + /** + * @throws DbLayerException + */ + private function ensureParamsAreLoaded(): void + { + $userId = $this->permissionChecker->getUserId(); + if ($userId === null) { + throw new \RuntimeException('No authenticated user found.'); + } + + if (isset($this->params[$userId])) { + return; + } + + $result = $this->dbLayer->buildAndQuery([ + 'SELECT' => 'name, value', + 'FROM' => self::TABLE_NAME, + 'WHERE' => 'user_id = :user_id', + ], [ + 'user_id' => $userId + ]); + + $this->params[$userId] = $result->fetchAll(\PDO::FETCH_KEY_PAIR); + foreach ($this->params[$userId] as $key => $value) { + try { + $this->params[$userId][$key] = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \LogicException('Failed to decode user settings.', 0, $e); + } + } + } + + public function clearState(): void + { + $this->params = []; + } +} diff --git a/_include/src/Extensions/ExtensionManagerAdapter.php b/_include/src/Extensions/ExtensionManagerAdapter.php index bac6caf..ca33721 100644 --- a/_include/src/Extensions/ExtensionManagerAdapter.php +++ b/_include/src/Extensions/ExtensionManagerAdapter.php @@ -13,22 +13,22 @@ use S2\AdminYard\Config\AdminConfig; use S2\AdminYard\Config\FieldConfig; use S2\AdminYard\Form\FormParams; +use S2\AdminYard\SettingStorage\SettingStorageInterface; use S2\AdminYard\TemplateRenderer; use S2\AdminYard\Translator; use S2\Cms\Admin\AdminConfigExtenderInterface; use S2\Cms\Framework\Exception\AccessDeniedException; use S2\Cms\Model\PermissionChecker; use S2\Cms\Pdo\DbLayerException; -use Symfony\Component\HttpFoundation\RequestStack; readonly class ExtensionManagerAdapter implements AdminConfigExtenderInterface { - public function __construct( - private ExtensionManager $extensionManager, - private PermissionChecker $permissionChecker, - private Translator $translator, - private RequestStack $requestStack, - private TemplateRenderer $templateRenderer, + public function __construct( + private ExtensionManager $extensionManager, + private PermissionChecker $permissionChecker, + private Translator $translator, + private ?SettingStorageInterface $settingStorage, + private TemplateRenderer $templateRenderer, ) { } @@ -105,7 +105,7 @@ private function getCsrfToken(string $id): string { // This token is used for every action in the extension actions. // I chose to use ACTION_DELETE since then it would be compatible with the AdminYard delete token. - $formParams = new FormParams('Extension', [], $this->requestStack->getMainRequest(), FieldConfig::ACTION_DELETE, ['id' => $id]); + $formParams = new FormParams('Extension', [], $this->settingStorage, FieldConfig::ACTION_DELETE, ['id' => $id]); return $formParams->getCsrfToken(); } diff --git a/_include/src/Model/ArticleManager.php b/_include/src/Model/ArticleManager.php index 8fcc0a0..26a65ed 100644 --- a/_include/src/Model/ArticleManager.php +++ b/_include/src/Model/ArticleManager.php @@ -11,20 +11,20 @@ use S2\AdminYard\Config\FieldConfig; use S2\AdminYard\Form\FormParams; +use S2\AdminYard\SettingStorage\SettingStorageInterface; use S2\Cms\Framework\Exception\AccessDeniedException; use S2\Cms\Framework\Exception\NotFoundException; use S2\Cms\Pdo\DbLayer; use S2\Cms\Pdo\DbLayerException; -use Symfony\Component\HttpFoundation\RequestStack; readonly class ArticleManager { public function __construct( - private DbLayer $dbLayer, - private RequestStack $requestStack, - private PermissionChecker $permissionChecker, - private bool $newPositionOnTop, - private bool $useHierarchy, + private DbLayer $dbLayer, + private SettingStorageInterface $settingStorage, + private PermissionChecker $permissionChecker, + private bool $newPositionOnTop, + private bool $useHierarchy, ) { } @@ -345,7 +345,7 @@ public function getCsrfToken(int $id): string { // This token is used for every action in the tree management actions. // I chose to use ACTION_DELETE since then it would be compatible with the AdminYard delete token. - $formParams = new FormParams('Article', [], $this->requestStack->getMainRequest(), FieldConfig::ACTION_DELETE, ['id' => (string)$id]); + $formParams = new FormParams('Article', [], $this->settingStorage, FieldConfig::ACTION_DELETE, ['id' => (string)$id]); return $formParams->getCsrfToken(); } diff --git a/_include/src/Model/Installer.php b/_include/src/Model/Installer.php index ea0f5a1..accfba3 100644 --- a/_include/src/Model/Installer.php +++ b/_include/src/Model/Installer.php @@ -9,6 +9,7 @@ namespace S2\Cms\Model; +use S2\Cms\AdminYard\UserSettingStorage; use S2\Cms\Pdo\DbLayer; use S2\Cms\Pdo\DbLayerException; @@ -258,7 +259,7 @@ public function createTables(): void )); $this->dbLayer->createTable('art_comments', array( - 'FIELDS' => array( + 'FIELDS' => array( 'id' => array( 'datatype' => 'SERIAL', 'allow_null' => false @@ -317,7 +318,7 @@ public function createTables(): void 'allow_null' => true ), ), - 'PRIMARY KEY' => array('id'), + 'PRIMARY KEY' => array('id'), 'FOREIGN KEYS' => array( 'fk_article' => array( 'columns' => ['article_id'], @@ -326,15 +327,15 @@ public function createTables(): void 'on_delete' => 'CASCADE', ) ), - 'INDEXES' => array( - 'sort_idx' => array('article_id', 'time', 'shown'), - 'time_idx' => array('time') + 'INDEXES' => array( + 'sort_idx' => array('article_id', 'time', 'shown'), + 'time_idx' => array('time') ) )); $this->dbLayer->createTable('tags', array( 'FIELDS' => array( - 'id' => array( + 'id' => array( 'datatype' => 'SERIAL', 'allow_null' => false ), @@ -366,7 +367,7 @@ public function createTables(): void )); $this->dbLayer->createTable('article_tag', array( - 'FIELDS' => array( + 'FIELDS' => array( 'id' => array( 'datatype' => 'SERIAL', 'allow_null' => false @@ -380,7 +381,7 @@ public function createTables(): void 'allow_null' => false, ), ), - 'PRIMARY KEY' => array('id'), + 'PRIMARY KEY' => array('id'), 'FOREIGN KEYS' => array( 'fk_article' => array( 'columns' => ['article_id'], @@ -388,21 +389,21 @@ public function createTables(): void 'reference_columns' => ['id'], 'on_delete' => 'CASCADE', ), - 'fk_tag' => array( + 'fk_tag' => array( 'columns' => ['tag_id'], 'reference_table' => 'tags', 'reference_columns' => ['id'], 'on_delete' => 'CASCADE', ), ), - 'INDEXES' => array( + 'INDEXES' => array( 'article_id_idx' => array('article_id'), 'tag_id_idx' => array('tag_id'), ), )); $this->dbLayer->createTable('users_online', array( - 'FIELDS' => array( + 'FIELDS' => array( 'challenge' => array( 'datatype' => 'VARCHAR(32)', 'allow_null' => false, @@ -446,14 +447,40 @@ public function createTables(): void 'on_delete' => 'CASCADE', ) ), - 'INDEXES' => array( + 'INDEXES' => array( 'login_idx' => array('login'), ), - 'UNIQUE KEYS' => array( + 'UNIQUE KEYS' => array( 'challenge_idx' => array('challenge'), ) )); + $this->dbLayer->createTable(UserSettingStorage::TABLE_NAME, [ + 'FIELDS' => [ + 'user_id' => [ + 'datatype' => 'INT(10) UNSIGNED', + 'allow_null' => false + ], + 'name' => [ + 'datatype' => 'VARCHAR(191)', + 'allow_null' => false + ], + 'value' => [ + 'datatype' => 'TEXT', + 'allow_null' => false + ], + ], + 'PRIMARY KEY' => ['user_id', 'name'], + 'FOREIGN KEYS' => [ + 'fk_user' => [ + 'columns' => ['user_id'], + 'reference_table' => 'users', + 'reference_columns' => ['id'], + 'on_delete' => 'CASCADE', + ], + ] + ]); + $this->dbLayer->createTable('queue', array( 'FIELDS' => array( 'id' => array( @@ -485,6 +512,7 @@ public function dropTables(): void $this->dbLayer->dropTable('articles'); $this->dbLayer->dropTable('extensions'); $this->dbLayer->dropTable('config'); + $this->dbLayer->dropTable(UserSettingStorage::TABLE_NAME); $this->dbLayer->dropTable('users_online'); $this->dbLayer->dropTable('users'); } diff --git a/_include/src/Pdo/DbLayer.php b/_include/src/Pdo/DbLayer.php index 243da2c..72934c9 100644 --- a/_include/src/Pdo/DbLayer.php +++ b/_include/src/Pdo/DbLayer.php @@ -96,14 +96,33 @@ public function build(array $query): string if (!empty($query['WHERE'])) { $sql .= ' WHERE ' . $query['WHERE']; } - } else if (isset($query['REPLACE'])) { - $sql = 'REPLACE INTO ' . (isset($query['PARAMS']['NO_PREFIX']) ? '' : $this->prefix) . $query['INTO']; + } else if (isset($query['UPSERT'])) { + /** + * INSERT INTO table_name (column1, column2, ...) + * VALUES (value1, value2, ...) + * ON DUPLICATE KEY UPDATE column1 = VALUES(column1), column2 = VALUES(column2), ...; + */ + $sql = 'INSERT INTO ' . (isset($query['PARAMS']['NO_PREFIX']) ? '' : $this->prefix) . $query['INTO']; + + if (!empty($query['UPSERT'])) { + $sql .= ' (' . $query['UPSERT'] . ')'; + } - if (!empty($query['REPLACE'])) { - $sql .= ' (' . $query['REPLACE'] . ')'; + $uniqueFields = explode(',', $query['UNIQUE']); + $uniqueFields = array_map('trim', $uniqueFields); + $uniqueFields = array_flip($uniqueFields); + + $set = ''; + foreach (explode(',', $query['UPSERT']) as $field) { + if (isset($uniqueFields[$field])) { + continue; + } + $field = trim($field); + $set .= $field . ' = VALUES(' . $field . '),'; } + $set = rtrim($set, ','); - $sql .= ' VALUES(' . $query['VALUES'] . ')'; + $sql .= ' VALUES(' . $query['VALUES'] . ') ON DUPLICATE KEY UPDATE ' . $set; } return $sql; diff --git a/_include/src/Pdo/DbLayerPostgres.php b/_include/src/Pdo/DbLayerPostgres.php index 67788fb..2041606 100644 --- a/_include/src/Pdo/DbLayerPostgres.php +++ b/_include/src/Pdo/DbLayerPostgres.php @@ -22,6 +22,43 @@ class DbLayerPostgres extends DbLayer '/^FLOAT( )?(\\([0-9]+\\))?( )?(UNSIGNED)?$/i' => 'REAL' ]; + public function build(array $query): string + { + if (isset($query['UPSERT'])) { + /** + * INSERT INTO table_name (column1, column2, ...) + * VALUES (value1, value2, ...) + * ON CONFLICT (conflict_target) DO UPDATE + * SET column1 = EXCLUDED.column1, column2 = EXCLUDED.column2, ...; + * */ + $sql = 'INSERT INTO ' . (isset($query['PARAMS']['NO_PREFIX']) ? '' : $this->prefix) . $query['INTO']; + + if (!empty($query['UPSERT'])) { + $sql .= ' (' . $query['UPSERT'] . ')'; + } + + $uniqueFields = explode(',', $query['UNIQUE']); + $uniqueFields = array_map('trim', $uniqueFields); + $uniqueFields = array_flip($uniqueFields); + + $set = ''; + foreach (explode(',', $query['UPSERT']) as $field) { + if (isset($uniqueFields[$field])) { + continue; + } + $field = trim($field); + $set .= $field . ' = EXCLUDED.' . $field . ','; + } + $set = rtrim($set, ','); + + $sql .= ' VALUES(' . $query['VALUES'] . ') ON CONFLICT (' . $query['UNIQUE'] . ') DO UPDATE SET ' . $set; + + return $sql; + } + + return parent::build($query); + } + public function escape($str): string { return \is_array($str) ? '' : substr($this->pdo->quote($str), 1, -1); diff --git a/_include/src/Pdo/DbLayerSqlite.php b/_include/src/Pdo/DbLayerSqlite.php index aa538d6..d6fdfc0 100644 --- a/_include/src/Pdo/DbLayerSqlite.php +++ b/_include/src/Pdo/DbLayerSqlite.php @@ -19,6 +19,43 @@ class DbLayerSqlite extends DbLayer '/^(TINY|MEDIUM|LONG)?TEXT$/i' => 'TEXT' ]; + public function build(array $query): string + { + if (isset($query['UPSERT'])) { + /** + * INSERT INTO table_name (column1, column2, ...) + * VALUES (value1, value2, ...) + * ON CONFLICT (conflict_target) DO UPDATE + * SET column1 = excluded.column1, column2 = excluded.column2, ...; + * */ + $sql = 'INSERT INTO ' . (isset($query['PARAMS']['NO_PREFIX']) ? '' : $this->prefix) . $query['INTO']; + + if (!empty($query['UPSERT'])) { + $sql .= ' (' . $query['UPSERT'] . ')'; + } + + $uniqueFields = explode(',', $query['UNIQUE']); + $uniqueFields = array_map('trim', $uniqueFields); + $uniqueFields = array_flip($uniqueFields); + + $set = ''; + foreach (explode(',', $query['UPSERT']) as $field) { + if (isset($uniqueFields[$field])) { + continue; + } + $field = trim($field); + $set .= $field . ' = excluded.' . $field . ','; + } + $set = rtrim($set, ','); + + $sql .= ' VALUES(' . $query['VALUES'] . ') ON CONFLICT (' . $query['UNIQUE'] . ') DO UPDATE SET ' . $set; + + return $sql; + } + + return parent::build($query); + } + public function escape($str): string { return \is_array($str) ? '' : substr($this->pdo->quote($str), 1, -1); diff --git a/_tests/integration/DbLayerCest.php b/_tests/integration/DbLayerCest.php index b82fa82..b17c9eb 100644 --- a/_tests/integration/DbLayerCest.php +++ b/_tests/integration/DbLayerCest.php @@ -54,6 +54,65 @@ public function testIndexExists(\IntegrationTester $I): void $I->assertFalse($this->dbLayer->indexExists('art_comments', 'not_an_index')); } + /** + * @throws DbLayerException + */ + public function testInsertOrUpdate(\IntegrationTester $I): void + { + $result = $this->dbLayer->buildAndQuery([ + 'SELECT' => '*', + 'FROM' => 'config', + 'WHERE' => 'name = :name', + ], [ + 'name' => 'S2_FAVORITE_URL', + ]); + $data = $this->dbLayer->fetchAssocAll($result); + $I->assertCount(1, $data); + $I->assertEquals(['name' => 'S2_FAVORITE_URL', 'value' => 'favorite'], $data[0]); + + $this->dbLayer->buildAndQuery([ + 'UPSERT' => 'name, value', + 'INTO' => 'config', + 'UNIQUE' => 'name', + 'VALUES' => ':name, :value', + ], [ + 'name' => 'S2_FAVORITE_URL', + 'value' => 'favorite2', + ]); + + $result = $this->dbLayer->buildAndQuery([ + 'SELECT' => '*', + 'FROM' => 'config', + 'WHERE' => 'name = :name', + ], [ + 'name' => 'S2_FAVORITE_URL', + ]); + $data = $this->dbLayer->fetchAssocAll($result); + $I->assertCount(1, $data); + $I->assertEquals(['name' => 'S2_FAVORITE_URL', 'value' => 'favorite2'], $data[0]); + + $this->dbLayer->buildAndQuery([ + 'UPSERT' => 'name, value', + 'INTO' => 'config', + 'UNIQUE' => 'name', + 'VALUES' => ':name, :value', + ], [ + 'name' => 'S2_UNKNOWN', + 'value' => 'unknown', + ]); + + $result = $this->dbLayer->buildAndQuery([ + 'SELECT' => '*', + 'FROM' => 'config', + 'WHERE' => 'name = :name', + ], [ + 'name' => 'S2_UNKNOWN', + ]); + $data = $this->dbLayer->fetchAssocAll($result); + $I->assertCount(1, $data); + $I->assertEquals(['name' => 'S2_UNKNOWN', 'value' => 'unknown'], $data[0]); + } + /** * @throws DbLayerException */ diff --git a/composer.lock b/composer.lock index 6b877ab..666fa99 100644 --- a/composer.lock +++ b/composer.lock @@ -338,12 +338,12 @@ "source": { "type": "git", "url": "https://github.com/parpalak/admin-yard.git", - "reference": "4c218c58532bbb0821a5a9675fd70df32aeec770" + "reference": "f82ddf2e0808fdd176600f685d686673b04abff2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/parpalak/admin-yard/zipball/4c218c58532bbb0821a5a9675fd70df32aeec770", - "reference": "4c218c58532bbb0821a5a9675fd70df32aeec770", + "url": "https://api.github.com/repos/parpalak/admin-yard/zipball/f82ddf2e0808fdd176600f685d686673b04abff2", + "reference": "f82ddf2e0808fdd176600f685d686673b04abff2", "shasum": "" }, "require": { @@ -384,7 +384,7 @@ "issues": "https://github.com/parpalak/admin-yard/issues", "source": "https://github.com/parpalak/admin-yard/tree/master" }, - "time": "2024-08-06T19:59:51+00:00" + "time": "2024-08-10T20:09:43+00:00" }, { "name": "s2/rose",