diff --git a/.github/actions/run-tests/tests/test-entrypoint.sh b/.github/actions/run-tests/tests/test-entrypoint.sh index d57cadb2d..63a7e0509 100755 --- a/.github/actions/run-tests/tests/test-entrypoint.sh +++ b/.github/actions/run-tests/tests/test-entrypoint.sh @@ -121,7 +121,7 @@ else echo 'Copying the app code changes' echo 'This might cause trouble when dependencies have changed' - rsync -a /app/ apps/cookbook/ --exclude /.git --exclude /build --exclude /.github + rsync -a /app/ apps/cookbook/ --exclude /.git --exclude /build --exclude /.github --exclude /vendor fi diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 68a704226..340149834 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,6 +62,10 @@ jobs: - database: mysql coreVersion: stable19 phpVersion: "7.2" + mayFail: true + - database: mysql + coreVersion: stable19 + phpVersion: "7.3" mayFail: false # Test against master (optionally) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d7da516..306164818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ [#387](https://github.com/nextcloud/cookbook/pull/387) @TobiasMie - PHP linter and style checker enabled [#390](https://github.com/nextcloud/cookbook/pull/390) @christianlupus +- Database abstraction added for simpler access + [#407](https://github.com/nextcloud/cookbook/pull/407) @christianlupus ### Changed - Switch of project ownership to neextcloud organization in GitHub diff --git a/documentation/uml/dbwrappers.txt b/documentation/uml/dbwrappers.txt new file mode 100644 index 000000000..eab50b737 --- /dev/null +++ b/documentation/uml/dbwrappers.txt @@ -0,0 +1,208 @@ +@startuml + +namespace OCA.Cookbook { + + namespace Entity { + + interface Entity { + + persist() : void + + remove() : void + + reload() : void + } + + interface RecipeEntity { + -- + + getId() : int + + getName() : string + + setName(string) : void + .. + + getCategory() : CategoryEntity + + setCategory(CategoryEntity) : void + + getKeywords() : array + + addKeyword(KeywordEntity) : void + + removeKeyword(KeywordEntity) : void + } + + interface CategoryEntity { + -- + + getName() : string + + setName(string) : void + .. + + getRecipes() : array + } + + interface KeywordEntity { + -- + + getName() : string + + setName(string) : void + .. + + getRecipes() : array + } + + + namespace impl { + + abstract class AbstractEntity { + - bool persisted + + isPersisted() : bool + + abstract clone() : ImplEntity + + abstract isSame(AbstractEntity) : bool + + abstract equals(AbstractEntity) : bool + } + + class RecipeEntityImpl { + #id + #name + #userId + # RecipeDbWrapper wrapper + # CategoryEntityImpl newCategory + # array newKeywords + # array removedKeywords + + getNewCategory() : CategoryEntityImpl | null + + getNewKeywords() : array + + getRemovedKeywords() : array + } + + class CategoryEntityImpl { + #name + # CategoryDbWrapper wrapper + } + + class KeywordEntityImpl { + #name + # KeywordDbWrapper wrapper + } + + class CategoryMappingEntity { + } + + class KeywordMappingEntity { + } + + RecipeEntityImpl --|> AbstractEntity + CategoryEntityImpl --|> AbstractEntity + KeywordEntityImpl --|> AbstractEntity + + CategoryMappingEntity -|> AbstractEntity + AbstractEntity <|- KeywordMappingEntity + + RecipeEntityImpl "1" *--x CategoryMappingEntity + RecipeEntityImpl "1" *--x KeywordMappingEntity + CategoryEntityImpl "1" *--x CategoryMappingEntity + KeywordEntityImpl "1" *--x KeywordMappingEntity + + } + + RecipeEntity <|.. OCA.Cookbook.Entity.impl.RecipeEntityImpl + CategoryEntity <|.. OCA.Cookbook.Entity.impl.CategoryEntityImpl + KeywordEntity <|.. OCA.Cookbook.Entity.impl.KeywordEntityImpl + + Entity <|.. RecipeEntity + Entity <|.. CategoryEntity + Entity <|.. KeywordEntity + + } + + namespace Db { + + abstract class AbstractDbWrapper { + # IDbConnection db + - array cache + - bool cacheValid + -- + + abstract createEntity() : T + # abstract fetchDatabase() : array + + getEntites() : array + # setCache(array) : void + + getServiceLocator() : DbWrapperSerciveLocator + .. + + store(T) : void + # abstract storeNew(T) : void + # abstract update(T) : void + + remove(T) : void + } + note right + Cache represents the current state + of the database tables as clones. + endnote + + class RecipeDbWrapper { +' - array idMap + -- + + createEntity() : RecipeEntityImpl + + store(RecipeEntityImpl) : void + # storeNew(RecipeEntityImpl) : void + # update(RecipeEntityImpl) : void + + remove(RecipeEntityImpl) : void + .. + /'+ addKeyword(RecipeEntityImpl, KeywordEntityImpl) : KeywordMappingEntity + + setCategory(RecipeEntityImpl, CategoryEntityImpl) : CategoryMappingEntity'/ + + getCategory(RecipeEntityImpl) : CategoryEntityImpl + + getKeywords(RecipeEntityImpl) : array + } + class CategoryDbWrapper { + -- + + createEntity() : CategoryEntityImpl + + store(CategoryEntityImpl) : void + # storeNew(CategoryEntityImpl) : void + # update(CategoryEntityImpl) : void + + remove(CategoryEntityImpl) : void + .. + + getRecipes(CategoryEntityImpl) : array + } + class KeywordDbWrapper { + -- + + createEntity() : KeywordEntityImpl + + store(KeywordEntityImpl) : void + # storeNew(KeywordEntityImpl) : void + # update(KeywordEntityImpl) : void + + remove(KeywordEntityImpl) : void + .. + + getRecipes(KeywordEntityImpl) : array + } + + class CategoryMappingsDbWrapper { + + createEntity() : CategoryMappingEntity + + store(CategoryMappingEntity) : void + # storeNew(CategoryMappingEntity) : void + # update(CategoryMappingEntity) : void + + remove(CategoryMappingEntity) : void + } + + class KeywordMappingsDbWrapper { + + createEntity() : KeywordMappingEntity + + store(KeywordMappingEntity) : void + # storeNew(KeywordMappingEntity) : void + # update(KeywordMappingEntity) : void + + remove(KeywordMappingEntity) : void + } + + class DbWrapperSerciveLocator { + + getRecipeDbWrapoper() : RecipeDbWrapper + + getCategoryDbWrapper() : CategoryDbWrapper + + getKeywordDbWrapper() : KeywordDbWrapper + + getKeywordMappingDbWrapper() : KeywordMappingsDbWrapper + + getCategoryMappingDbWrapper() : CategoryMappingsDbWrapper + } + + AbstractDbWrapper <|-- RecipeDbWrapper + AbstractDbWrapper <|-- CategoryDbWrapper + AbstractDbWrapper <|-- KeywordDbWrapper + + RecipeDbWrapper "1" o-- "1" DbWrapperSerciveLocator + CategoryDbWrapper "1" o-- "1" DbWrapperSerciveLocator + KeywordDbWrapper "1" o-- "1" DbWrapperSerciveLocator + + CategoryMappingsDbWrapper "1" o- "1" DbWrapperSerciveLocator + DbWrapperSerciveLocator "1" -o "1" KeywordMappingsDbWrapper + + AbstractDbWrapper <|--- CategoryMappingsDbWrapper + AbstractDbWrapper <|--- KeywordMappingsDbWrapper + + DbWrapperSerciveLocator -[hidden]-> AbstractDbWrapper + } + +} + + +@enduml diff --git a/lib/Db/AbstractDbWrapper.php b/lib/Db/AbstractDbWrapper.php new file mode 100644 index 000000000..7cbf84758 --- /dev/null +++ b/lib/Db/AbstractDbWrapper.php @@ -0,0 +1,104 @@ +initialized = false; + $this->db = $db; + } + + /** + * Fetch all elements from the database. + * + * The concrete class must implemnt a way to fetch an array or elements represented by the database. + * @return array The (possible empty) array of entities in the database + */ + abstract protected function fetchDatabase(): array; + + /** + * Fetch the entries in the database + * + * If the cache has already been fetched, the values in the cache are returned. + * + * @return array The entities in the database. + */ + public function getEntries(): array { + if (! $this->initialized) { + $this->reloadCache(); + } + + return $this->cache; + } + + /** + * Reload the cache from the database. + */ + private function reloadCache(): void { + $this->cache = $this->fetchDatabase(); + $this->initialized = true; + } + + /** + * Invalidate the local cache. + * + * This will cause any access to the entities to refetch the whole cache. + */ + protected function invalidateCache(): void { + $this->initialized = false; + } + + /** + * Set the cache to the given array. + * + * This function will repace the current cache by the given array. + * No further checks are carried out. + * Please be very careful to provide the most up to date data as present in the database. + * Otherwise you will see very strange effects. + * + * @param array $entities The new entities of the cache + */ + protected function setEntites(array $entities): void { + $this->cache = $entities; + $this->initialized = true; + } + + /** + * Set the central service locator for registering of all wrappers + * @param DbWrapperServiceProvider $locator The locator for the registered wrappers + */ + public function setWrapperServiceLocator(DbWrapperServiceProvider $locator) { + $this->wrapperLocator = $locator; + } + + /** + * Get the central service locator for registering of all wrappers + * @return DbWrapperServiceProvider The locator for the registered wrappers + */ + public function getWrapperServiceLocator() { + return $this->wrapperLocator; + } +} diff --git a/lib/Db/CategoryDbWrapper.php b/lib/Db/CategoryDbWrapper.php new file mode 100644 index 000000000..c30936946 --- /dev/null +++ b/lib/Db/CategoryDbWrapper.php @@ -0,0 +1,102 @@ +userId = $UserId; + $this->db = $db; + } + + protected function fetchDatabase(): array { + $qb = $this->db->getQueryBuilder(); + + $qb ->select('name') + ->from(self::CATEGORIES) + ->where('user_id = :uid') + ->groupBy('name') + ->orderBy('name'); + + $qb->setParameter('uid', $this->userId); + + $res = $qb->execute(); + + $ret = []; + while ($row = $res->fetch()) { + $entity = $this->createEntity(); + $entity->setName($row['name']); + + $ret[] = $entity; + } + + $res->closeCursor(); + + return $ret; + } + + /** + * Store a single entity back to the database + * @param CategoryEntity $category The entity to store + */ + public function store(CategoryEntity $category): void { + // We do not need to store anything here. The categories are just virtually generated. + } + + /** + * Create a new entity and reegister it with this wrapper + * @return CategoryEntity The new entity + */ + public function createEntity(): CategoryEntityImpl { + return new CategoryEntityImpl($this); + } + + public function remove(CategoryEntity $category) { + // Remove all foreign links + $recipes = $category->getRecipes(); + + foreach ($recipes as $r) { + /** + * @var RecipeEntity $r + */ + $r->setCategory(null); + } + + // We cannot do anything as the categories are purely virtual. + } + + /** + * @param CategoryEntity $category + * @return RecipeEntityImpl[] The recipes associated with the category + */ + public function getRecipes(CategoryEntity $category) : array { + /** + * @var CategoryMappingEntityImpl[] $mappings + */ + $mappings = $this->wrapperLocator->getCategoryMappingDbWrapper()->getEntries(); + $mappings = array_filter($mappings, function (CategoryMappingEntityImpl $mapping) use ($category) { + return $mapping->getCategory()->isSame($category); + }); + return array_map(function (CategoryMappingEntityImpl $mapping) { + return $mapping->getRecipe(); + }, $mappings); + } +} diff --git a/lib/Db/CategoryMappingDbWrapper.php b/lib/Db/CategoryMappingDbWrapper.php new file mode 100644 index 000000000..9e3263c43 --- /dev/null +++ b/lib/Db/CategoryMappingDbWrapper.php @@ -0,0 +1,154 @@ +userId = $UserId; + $this->db = $db; + $this->l = $l; + } + + /** + * Create a new entity and reegister it with this wrapper + * @return CategoryMappingEntityImpl The new entity + */ + public function createEntity(): CategoryMappingEntityImpl { + return new CategoryMappingEntityImpl($this); + } + + protected function fetchDatabase(): array { + // FIXME + $qb = $this->db->getQueryBuilder(); + + $qb ->select('name', 'recipe_id') + ->from(self::CATEGORIES) + ->where('user_id = :uid'); + + $qb->setParameter('uid', $this->userId); + + $res = $qb->execute(); + $ret = []; + + while ($row = $res->fetch()) { + $recipe = $this->getWrapperServiceLocator()->getRecipeDbWrapper()->getRecipeById($row['recipe_id']); + + $category = $this->getWrapperServiceLocator()->getCategoryDbWrapper()->createEntity(); + $category->setName($row['name']); + + $entity = $this->createEntity(); + + $entity->setCategory($category); + $entity->setRecipe($recipe); + + $ret[] = $entity; + } + + $res->closeCursor(); + + return $ret; + } + + /** + * Store a single entity back to the database + * @param CategoryMappingEntityImpl $category The entity to store + */ + public function store(CategoryMappingEntityImpl $mapping): void { + if (! $mapping->getRecipe()->isPersisted()) { + throw new InvalidDbStateException($this->l->t('The recipe was not stored to the database yet. No id known.')); + } + + if ($mapping->isPersisted()) { + $this->update($mapping); + } else { + $this->storeNew($mapping); + } + } + + private function storeNew(CategoryMappingEntityImpl $mapping): void { + $qb = $this->db->getQueryBuilder(); + + $cache = $this->getEntries(); + + $qb ->insert(self::CATEGORIES) + ->values([ + 'recipe_id' => $mapping->getRecipe()->getId(), + 'user_id' => $this->userId, + 'name' => $mapping->getCategory()->getName() + ]); + $qb->execute(); + + $cache[] = $mapping->clone(); + $this->setEntites($cache); + } + + private function update(CategoryMappingEntityImpl $mapping): void { + $qb = $this->db->getQueryBuilder(); + + $cache = $this->getEntries(); + + $qb ->update(self::CATEGORIES) + ->set('name', $mapping->getCategory()->getName()) + ->where('recipe_id = :rid', 'user_id = :uid'); + $qb->setParameters([ + 'rid' => $mapping->getRecipe()->getId(), + 'uid' => $this->userId + ]); + $qb->execute(); + + $cache = array_map(function (CategoryMappingEntityImpl $m) use ($mapping) { + if ($m->isSame($mapping)) { + // We need to update + return $mapping->clone(); + } else { + return $m; + } + }, $cache); + $this->setEntites($cache); + } + + public function remove(CategoryMappingEntityImpl $mapper): void { + $qb = $this->db->getQueryBuilder(); + + $cache = $this->getEntries(); + + $qb ->delete(self::CATEGORIES) + ->where('recipe_id = :rid', 'user_id = :uid'); + $qb->setParameters([ + 'rid' => $mapper->getRecipe()->getId(), + 'uid' => $this->userId + ]); + + $qb->execute(); + + $cache = array_filter($cache, function (CategoryMappingEntityImpl $m) use ($mapper) { + return ! $m->isSame($mapper); + }); + $this->setEntites($cache); + + // XXX Remove CategoryEntity completely? + } +} diff --git a/lib/Db/DbWrapperServiceLocator.php b/lib/Db/DbWrapperServiceLocator.php new file mode 100644 index 000000000..d48d55427 --- /dev/null +++ b/lib/Db/DbWrapperServiceLocator.php @@ -0,0 +1,80 @@ +recipeDbWrapper = $recipeWrapper; + $this->categoryDbWrapper = $categoryWrapper; + $this->categoryMappingDbWrapper = $categoryMappingWrapper; + $this->keywordDbWrapper = $keywordWrapper; + $this->keywordMappingDbWrapper = $keywordMappingWrapper; + + // Register the service locator with each + $recipeWrapper->setWrapperServiceLocator($this); + $categoryWrapper->setWrapperServiceLocator($this); + $categoryMappingWrapper->setWrapperServiceLocator($this); + $keywordWrapper->setWrapperServiceLocator($this); + $keywordMappingWrapper->setWrapperServiceLocator($this); + } + /** + * @return \OCA\Cookbook\Db\RecipeDbWrapper + */ + public function getRecipeDbWrapper() { + return $this->recipeDbWrapper; + } + + /** + * @return \OCA\Cookbook\Db\CategoryDbWrapper + */ + public function getCategoryDbWrapper() { + return $this->categoryDbWrapper; + } + + /** + * @return \OCA\Cookbook\Db\CategoryMappingDbWrapper + */ + public function getCategoryMappingDbWrapper() { + return $this->categoryMappingDbWrapper; + } + + /** + * @return \OCA\Cookbook\Db\KeywordDbWrapper + */ + public function getKeywordDbWrapper() { + return $this->keywordDbWrapper; + } + + /** + * @return \OCA\Cookbook\Db\KeywordMappingDbWrapper + */ + public function getKeywordMappingDbWrapper() { + return $this->keywordMappingDbWrapper; + } +} diff --git a/lib/Db/KeywordDbWrapper.php b/lib/Db/KeywordDbWrapper.php new file mode 100644 index 000000000..6cd10dd92 --- /dev/null +++ b/lib/Db/KeywordDbWrapper.php @@ -0,0 +1,98 @@ +userId = $UserId; + $this->db = $db; + } + + protected function fetchDatabase(): array { + $qb = $this->db->getQueryBuilder(); + + $qb ->select('name') + ->from(self::KEYWORDS) + ->where('user_id = :uid') + ->groupBy('name') + ->orderBy('name'); + + $qb->setParameter('uid', $this->userId); + + $res = $qb->execute(); + $arr = []; + while ($row = $res->fetch()) { + $entity = $this->createEntity(); + $entity->setName($row['name']); + + $arr[] = $entity; + } + + $res->closeCursor(); + + return $arr; + } + + /** + * Store a single entity back to the database + * @param KeywordEntity $keyword The entity to store + */ + public function store(KeywordEntity $keyword): void { + // We do not need to store anything here. The keywords are just virtually generated. + } + + /** + * Create a new entity and reegister it with this wrapper + * @return KeywordEntity The new entity + */ + public function createEntity(): KeywordEntityImpl { + return new KeywordEntityImpl($this); + } + + /** + * @param KeywordEntity $keyword + * @return RecipeEntityImpl[] The recipes associated with the keyword + */ + public function getRecipes(KeywordEntity $keyword): array { + $mappings = $this->wrapperLocator->getKeywordMappingDbWrapper()->getEntries(); + $mappings = array_filter($mappings, function (KeywordMappingEntityImpl $mapping) use ($keyword) { + return $mapping->getKeyword()->isSame($keyword); + }); + return array_map(function (KeywordMappingEntityImpl $mapping) { + return $mapping->getRecipe(); + }, $mappings); + } + + public function remove(KeywordEntity $keyword): void { + // Remove all foreign links + $recipes = $keyword->getRecipes(); + + foreach ($recipes as $r) { + /** + * @var RecipeEntity $r + */ + $r->removeKeyword($keyword); + } + + // We cannot do anything as the categories are purely virtual. + } +} diff --git a/lib/Db/KeywordMappingDbWrapper.php b/lib/Db/KeywordMappingDbWrapper.php new file mode 100644 index 000000000..976778441 --- /dev/null +++ b/lib/Db/KeywordMappingDbWrapper.php @@ -0,0 +1,141 @@ +userId = $UserId; + $this->db = $db; + } + + /** + * Create a new entity and reegister it with this wrapper + * @return KeywordMappingEntityImpl The new entity + */ + public function createEntity(): KeywordMappingEntityImpl { + return new KeywordMappingEntityImpl($this); + } + + protected function fetchDatabase(): array { + $qb = $this->db->getQueryBuilder(); + + $qb ->select('name', 'recipe_id') + ->from(self::KEYWORDS) + ->where('user_id = :uid'); + + $qb->setParameter('uid', $this->userId); + + $res = $qb->execute(); + $ret = []; + + while ($row = $res->fetch()) { + $recipe = $this->getWrapperServiceLocator()->getRecipeDbWrapper()->getRecipeById($row['recipe_id']); + + $keyword = $this->getWrapperServiceLocator()->getKeywordDbWrapper()->createEntity(); + $keyword->setName($row['name']); + + $entity = $this->createEntity(); + + $entity->setRecipe($recipe); + $entity->setKeyword($keyword); + + $ret[] = $entity; + } + + $res->closeCursor(); + + return $ret; + } + + /** + * Store a single entity back to the database + * @param KeywordMappingEntityImpl $category The entity to store + */ + public function store(KeywordMappingEntityImpl $mapping): void { + if (! $mapping->getRecipe()->isPersisted()) { + throw new InvalidDbStateException($this->l->t('The recipe was not stored to the database yet. No id known.')); + } + + if ($mapping->isPersisted()) { + $this->update($mapping); + } else { + $this->storeNew($mapping); + } + } + + private function storeNew(KeywordMappingEntityImpl $mapping): void { + $qb = $this->db->getQueryBuilder(); + + $cache = $this->getEntries(); + + $qb ->insert(self::KEYWORDS) + ->values([ + 'recipe_id' => $mapping->getRecipe()->getId(), + 'user_id' => $this->userId, + 'name' => $mapping->getKeyword()->getName() + ]); + + $qb->execute(); + + $cache[] = $mapping; + $this->setEntites($cache); + } + + private function update(KeywordMappingEntityImpl $mapping): void { + $qb = $this->db->getQueryBuilder(); + + $cache = $this->getEntries(); + + $qb ->update(self::KEYWORDS) + ->set('name', $mapping->getKeyword()->getName()) + ->where('recipe_id = :rid', 'user_id = :uid'); + $qb->setParameter('rid', $mapping->getRecipe()->getId()); + $qb->setParameter('uid', $this->user); + + $qb->execute(); + + $cache = array_map(function (KeywordMappingEntityImpl $m) use ($mapping) { + if ($m->isSame($mapping)) { + return $mapping; + } else { + return $m; + } + }, $cache); + $this->setEntites($cache); + } + + public function remove(KeywordMappingEntityImpl $mapping): void { + $qb = $this->db->getQueryBuilder(); + + $cache = $this->getEntries(); + + $qb ->delete(self::KEYWORDS) + ->where('recipe_id = :rid', 'user_id = :uid'); + $qb->setParameter('rid', $mapping->getRecipe()->getId()); + $qb->setParameter('uid', $this->userId); + + $qb->execute(); + + $cache = array_filter($cache, function (KeywordMappingEntityImpl $m) use ($mapping) { + return ! $m->isSame($mapping); + }); + $this->setEntites($cache); + } +} diff --git a/lib/Db/RecipeDbWrapper.php b/lib/Db/RecipeDbWrapper.php new file mode 100644 index 000000000..7bd2925f3 --- /dev/null +++ b/lib/Db/RecipeDbWrapper.php @@ -0,0 +1,296 @@ +db = $db; + $this->userId = $UserId; + $this->l = $l; + } + + protected function fetchDatabase(): array { + $qb = $this->db->getQueryBuilder(); + + $qb ->select('id', 'name') + ->from('cookbook_names') + ->where('user_id = :uid'); + $qb->setParameter('uid', $this->userId); + + $res = $qb->execute(); + $ret = []; + + while ($row = $res->fetch()) { + $recipe = $this->createEntity(); + + $recipe->setName($row['name']); + $recipe->setId($row['id']); + + $ret[] = $recipe; + } + + return $ret; + } + + public function createEntity(): RecipeEntityImpl { + $ret = new RecipeEntityImpl($this); + $ret->setId(-1); + return $ret; + } + + public function store(RecipeEntityImpl $recipe): void { + if ($recipe->isPersisted()) { + $this->storeNew($recipe); + } else { + $this->store($recipe); + } + } + + private function storeNew(RecipeEntityImpl $recipe): void { + $qb = $this->db->getQueryBuilder(); + + $cache = $this->getEntries(); + + $qb ->insert(self::NAMES) + ->values([ + 'name' => $recipe->getName(), + 'user_id' => $this->userId + ]); + + $qb->execute(); + $recipe->setId($qb->getLastInsertId()); + + // Update cache + $cache[] = $recipe->clone(); + $this->setEntites($cache); + + // Set category mapping + $cat = $recipe->getCategory(); + $cat->persist(); + + $cm = $this->getWrapperServiceLocator()->getCategoryMappingDbWrapper()->createEntity(); + $cm->setCategory($cat); + $cm->setRecipe($recipe); + $cm->persist(); + + // Set keyword setting + $keywordMapperWrapper = $this->getWrapperServiceLocator()->getKeywordMappingDbWrapper(); + foreach ($recipe->getKeywords() as $keyword) { + /** + * @var KeywordEntityImpl $keyword + */ + $keyword->persist(); + + $km = $keywordMapperWrapper->createEntity(); + $km->setRecipe($recipe); + $km->setKeyword($keyword); + $km->persist(); + } + } + + private function update(RecipeEntityImpl $recipe): void { + $qb = $this->db->getQueryBuilder(); + + $cache = $this->getEntries(); + + $qb ->update(self::NAMES) + ->set('name', ':name') + ->where('recipe_id = :rid'); + $qb->setParameter('rid', $recipe->getId()); + $qb->setParameter('name', $recipe->getName()); + + $qb->execute(); + + // Update cache + $cache = array_map(function (RecipeEntityImpl $r) use ($recipe) { + if ($r->isSame($recipe)) { + return $recipe; + } else { + return $r; + } + }, $cache); + $this->setEntites($cache); + + // Update the category if needed + $newCategory = $recipe->getNewCategory(); + if ($recipe->newCategoryWasSet() && ! is_null($newCategory)) { + $oldMappings = $this->getRecipeCategoryMappings($recipe); + + $newCategory->persist(); + + if (count($oldMappings) == 0) { + // We need to insert a new Mapping + $mapping = $this->getWrapperServiceLocator()->getCategoryMappingDbWrapper()->createEntity(); + $mapping->setCategory($newCategory); + $mapping->setRecipe($recipe); + $mapping->persist(); + } else { + $mapping = $oldMappings[0]; + $mapping->setCategory($newCategory); + $mapping->persist(); + } + } + + // Update the keywords + $kwWrapper = $this->getWrapperServiceLocator()->getKeywordMappingDbWrapper(); + + foreach ($recipe->getNewKeywords() as $kw) { + /** + * @var KeywordEntityImpl $kw + */ + $kw->persist(); + + $mapping = $kwWrapper->createEntity(); + $mapping->setRecipe($recipe); + $mapping->setKeyword($kw); + $mapping->persist(); + } + + $removedKeywords = $recipe->getRemovedKeywords(); + $currentKwMappings = $this->getRecipeKeywordMappings($recipe); + $removingMappings = array_filter($currentKwMappings, function (KeywordMappingEntityImpl $m) use ($removedKeywords) { + foreach ($removedKeywords as $kw) { + /** + * @var KeywordEntityImpl $kw + */ + if ($m->getKeyword()->isSame($kw)) { + return true; + } + } + return false; + }); + + foreach ($removingMappings as $mapping) { + /** + * @var KeywordMappingEntityImpl $mapping + */ + $mapping->remove(); + } + } + + public function remove(RecipeEntityImpl $recipe): void { + if (! $recipe->isPersisted()) { + throw new InvalidDbStateException($this->l->t('Cannot remove recipe that was not yet saved.')); + } + + $catMappings = $this->getRecipeCategoryMappings($recipe); + foreach ($catMappings as $catMapping) { + /** + * @var CategoryMappingEntityImpl $catMapping + */ + $catMapping->remove(); + } + + $keywordMappings = $this->getRecipeKeywordMappings($recipe); + foreach ($keywordMappings as $keywordMapping) { + /** + * @var KeywordMappingEntityImpl $keywordMapping + */ + $keywordMapping->remove(); + } + + $qb = $this->db->getQueryBuilder(); + + $cache = $this->getEntries(); + + $qb ->delete(self::NAMES) + ->where('recipe_id = :rid'); + $qb->setParameter('rid', $recipe->getId()); + + $qb->execute(); + + $cache = array_filter($cache, function (RecipeEntityImpl $r) use ($recipe) { + return ! $r->isSame($recipe); + }); + $this->setEntites($cache); + } + + public function getRecipeById(int $id): RecipeEntity { + $entities = $this->getEntries(); + + foreach ($entities as $entry) { + /** + * @var RecipeEntity $entry + */ + if ($entry->getId() == $id) { + return $entry; + } + } + + throw new EntityNotFoundException($this->l->t('Recipe with id %d was not found.', $id)); + } + + public function getCategory(RecipeEntity $recipe): ?CategoryEntityImpl { + $mappings = $this->getRecipeCategoryMappings($recipe); + + if (count($mappings) == 0) { + return null; + } + + return $mappings[0]->getCategory(); + } + + private function getRecipeCategoryMappings(RecipeEntityImpl $recipe): array { + $mappings = $this->getWrapperServiceLocator()->getCategoryMappingDbWrapper()->getEntries(); + $mappings = array_filter($mappings, function (CategoryMappingEntityImpl $c) use ($recipe) { + return $c->getRecipe()->isSame($recipe); + }); + + if (count($mappings) > 1) { + throw new InvalidDbStateException($this->l->t('Multiple categopries for a single recipe found.')); + } + + return $mappings; + } + + /** + * @param RecipeEntity $recipe + * @return KeywordEntityImpl[] + */ + public function getKeywords(RecipeEntity $recipe): array { + $mappings = $this->getRecipeKeywordMappings($recipe); + $keywords = array_map(function (KeywordMappingEntityImpl $m) { + return $m->getKeyword(); + }, $mappings); + + return $keywords; + } + + private function getRecipeKeywordMappings(RecipeEntityImpl $recipe): array { + $mappings = $this->getWrapperServiceLocator()->getKeywordMappingDbWrapper()->getEntries(); + $mappings = array_filter($mappings, function (KeywordMappingEntityImpl $m) use ($recipe) { + return $m->getRecipe()->isSame($recipe); + }); + + return $mappings; + } +} diff --git a/lib/Entity/CategoryEntity.php b/lib/Entity/CategoryEntity.php new file mode 100644 index 000000000..9965ebae0 --- /dev/null +++ b/lib/Entity/CategoryEntity.php @@ -0,0 +1,19 @@ +json); + } + + + //// ************************** Getters and Setters of basic JSON data + + /** + * Get the name of the recipe + * @return string The name of the recipe + */ + public function getName() : string { + return $this->json['name']; + } + + /** + * Set the name of the recipe + * @param string The new name of the recipe + */ + public function setName(string $name) : void { + $this->json['name'] = $name; + $this->changed = true; + } + + /** + * Get the description of the recipe + * @return string The description + */ + public function getDescription() : string { + return $this->json['description']; + } + + /** + * Set the description of the recipe + * @param string The new description + */ + public function setDescription(string $description) : void { + $this->json['description'] = $description; + $this->changed = true; + } + + /** + * Get the URL of the recipe + * @return string The URL of the recipe + */ + public function getUrl() : string { + return $this->json['url']; + } + + /** + * Set the URL of the recipe + * @param string The new URL + */ + public function setUrl(string $url) : void { + $this->json['url'] = $url; + $this->changed = true; + } + + /** + * Get the amount of servings the recipe yields + * @return int The amount of servings + */ + public function getYield() : int { + return $this->json['recipeYield']; + } + + /** + * Set the amount of portions teh recipe yields + * @param int The amount of portions + */ + public function setYield(int $yield) : void { + $this->json['recipeYield'] = (int) $yield; + $this->changed = true; + } + + /** + * Get the preparation time for the recipe + * @return string The preparation time + */ + public function getPrepTime() : string { + return $this->json['prepTime']; + } + + /** + * Set the preparation time of a recipe + * @param string The new preparation time + */ + public function setPrepTime(string $prepTime) : void { + $this->json['prepTime'] = $prepTime; + $this->changed = true; + } + /** + * Get the cooking time for the recipe + * @return string The cooking time + */ + public function getCookTime() : string { + return $this->json['cookTime']; + } + + /** + * Set the cooking time of a recipe + * @param string The new cooking time + */ + public function setCookTime(string $cookTime) : void { + $this->json['cookTime'] = $cookTime; + $this->changed = true; + } + /** + * Get the total time for the recipe + * @return string The total time + */ + public function getTotalTime() : string { + return $this->json['totalTime']; + } + + /** + * Set the total time of a recipe + * @param string The new total time + */ + public function setTotalTime(string $totalTime) : void { + $this->json['prepTime'] = $totalTime; + $this->changed = true; + } + + /** + * Get the ingredients of the recipe + * @return array The ingredients + */ + public function getIngredients() : array { + return $this->json['recipeIngredient']; + } + + /** + * Set the ingredients of a recipe + * @param array The list of ingredients of the recipe + */ + public function setIngredients(array $ingredients) : void { + $this->json['recipeIngredient'] = $ingredients; + $this->changed = true; + } + + /** + * Get the tools of the recipe + * @return array The tools + */ + public function getTools() : array { + return $this->json['tool']; + } + + /** + * Set the tools of a recipe + * @param array The list of tools of the recipe + */ + public function setTools(array $tools) : void { + $this->json['tool'] = $tools; + $this->changed = true; + } + + /** + * Get the instructions of the recipe + * @return array The instructions + */ + public function getInstructions() : array { + return $this->json['recipeInstructions']; + } + + /** + * Set the instructions of a recipe + * @param array The list of instructions of the recipe + */ + public function setInstructions(array $instructions) : void { + $this->json['recipeInstructions'] = $instructions; + $this->changed = true; + } + + //// ****************************** Getters and setters for the category and keywords + + /** + * Get the category of a recipe + * @todo Use a category class instead of strings + * @return string The category of the recipe + */ + public function getCategory() : string { + return $this->json['recipeCategory']; + } + + /** + * Set the category of a recipe + * @todo Use category class instead of string + * @param string The new category + */ + public function setCategory(string $category) : void { + $this->json['recipeCategory'] = $category; + $this->changed = false; + } + + /** + * Get the keywords of the recipe + * @todo Use keyword call instead of strings + * @return array The keywords of the recipe + */ + public function getKeywords() : array { + $keywords = $this->json['keywords']; + + if (strlen(trim($keywords)) == 0) { + return []; + } + + return explode(',', $keywords); + } + + /** + * Set the keywords of the recipe + * @todo Use Keyword call instead of strings + * @param array The keywords as an array of strings + */ + public function setKeywords(array $keywords) : void { + $this->json['keywords'] = implode(',', $keywords); + $this->changed = true; + } +} diff --git a/lib/Entity/impl/AbstractEntity.php b/lib/Entity/impl/AbstractEntity.php new file mode 100644 index 000000000..e783ce613 --- /dev/null +++ b/lib/Entity/impl/AbstractEntity.php @@ -0,0 +1,49 @@ +persisted = $persisted; + } + + /** + * Check if the entity has been saved once + * @return bool true, if the element is alreday in the database + */ + public function isPersisted(): bool { + return $this->persisted; + } + + protected function setPersisted(): void { + $this->persisted = true; + } + + abstract public function clone(): AbstractEntity; + + public function isSame(AbstractEntity $other): bool { + if (get_class($this) !== get_class($other)) { + throw new InvalidComparisionException(); + } + + return $this->isSameImpl($other); + } + public function equals(AbstractEntity $other): bool { + if (get_class($this) !== get_class($other)) { + throw new InvalidComparisionException(); + } + + return $this->equalsImpl($other); + } + + abstract protected function isSameImpl(AbstractEntity $other): bool; + abstract protected function equalsImpl(AbstractEntity $other): bool; +} diff --git a/lib/Entity/impl/CategoryEntityImpl.php b/lib/Entity/impl/CategoryEntityImpl.php new file mode 100644 index 000000000..1321821cf --- /dev/null +++ b/lib/Entity/impl/CategoryEntityImpl.php @@ -0,0 +1,83 @@ +wrapper = $wrapper; + } + + /** + * Get the name of the category + * @return string The name of the category + */ + public function getName(): string { + return $this->name; + } + + /** + * Set the name of the category. + * @param string $name The new name of the category + */ + public function setName(string $name): void { + $this->name = $name; + } + + public function persist(): void { + $this->wrapper->store($this); + $this->setPersisted(); + } + + /** + * {@inheritDoc} + * @see \OCA\Cookbook\Entity\impl\AbstractEntity::clone() + * @return CategoryEntityImpl + */ + public function clone(): AbstractEntity { + $ret = $this->wrapper->createEntity(); + $ret->setName($this->name); + if ($this->isPersisted()) { + $ret->setPersisted(); + } + return $ret; + } + + public function remove(): void { + $this->wrapper->remove($this); + } + + public function reload(): void { + // FIXME + } + + protected function equalsImpl(AbstractEntity $other): bool { + return $this->name === $other->name; + } + + protected function isSameImpl(AbstractEntity $other): bool { + return $this->name === $other->name; + } + + public function getRecipes(): array { + return $this->wrapper->getRecipes($this); + } +} diff --git a/lib/Entity/impl/CategoryMappingEntityImpl.php b/lib/Entity/impl/CategoryMappingEntityImpl.php new file mode 100644 index 000000000..020101c01 --- /dev/null +++ b/lib/Entity/impl/CategoryMappingEntityImpl.php @@ -0,0 +1,96 @@ +wrapper = $wrapper; + } + + public function persist(): void { + $this->wrapper->store($this); + $this->setPersisted(); + } + + /** + * @return RecipeEntityImpl + */ + public function getRecipe(): RecipeEntityImpl { + return $this->recipe; + } + + /** + * @return CategoryEntityImpl + */ + public function getCategory(): CategoryEntityImpl { + return $this->category; + } + + /** + * @param RecipeEntityImpl $recipe + */ + public function setRecipe(RecipeEntityImpl $recipe) { + $this->recipe = $recipe; + } + + /** + * @param CategoryEntityImpl $category + */ + public function setCategory(CategoryEntityImpl $category) { + $this->category = $category; + } + + /** + * {@inheritDoc} + * @see \OCA\Cookbook\Entity\impl\AbstractEntity::clone() + * @return CategoryMappingEntityImpl + */ + public function clone(): AbstractEntity { + $ret = $this->wrapper->createEntity(); + + $ret->setCategory($this->category); + $ret->setRecipe($this->recipe); + + if ($this->isPersisted()) { + $ret->setPersisted(); + } + + return $ret; + } + + public function remove(): void { + $this->wrapper->remove($this); + } + + protected function equalsImpl(AbstractEntity $other): bool { + return $this->category->isSame($other->category) && $this->recipe->isSame($other->recipe); + } + + protected function isSameImpl(AbstractEntity $other): bool { + return $this->equalsImpl($other); + } +} diff --git a/lib/Entity/impl/KeywordEntityImpl.php b/lib/Entity/impl/KeywordEntityImpl.php new file mode 100644 index 000000000..7c44e57a0 --- /dev/null +++ b/lib/Entity/impl/KeywordEntityImpl.php @@ -0,0 +1,83 @@ +wrapper = $wrapper; + } + + /** + * Get the name of the keyword + * @return string The name of the keyword + */ + public function getName(): string { + return $this->name; + } + + /** + * Set the name of the keyword + * @param string $name The name of the keyword + */ + public function setName($name): void { + $this->name = $name; + } + + public function persist(): void { + $this->wrapper->store($this); + $this->setPersisted(); + } + + public function reload(): void { + // FIXME + } + + /** + * {@inheritDoc} + * @see \OCA\Cookbook\Entity\impl\AbstractEntity::clone() + * @return KeywordEntityImpl + */ + public function clone(): AbstractEntity { + $ret = $this->wrapper->createEntity(); + $ret->name = $this->name; + if ($this->isPersisted()) { + $ret->setPersisted(); + } + return $ret; + } + + protected function equalsImpl(AbstractEntity $other): bool { + return $this->name === $other->name; + } + + protected function isSameImpl(AbstractEntity $other): bool { + return $this->name === $other->name; + } + + public function getRecipes(): array { + return $this->wrapper->getRecipes($this); + } + + public function remove(): void { + $this->wrapper->remove($this); + } +} diff --git a/lib/Entity/impl/KeywordMappingEntityImpl.php b/lib/Entity/impl/KeywordMappingEntityImpl.php new file mode 100644 index 000000000..054c2dd5e --- /dev/null +++ b/lib/Entity/impl/KeywordMappingEntityImpl.php @@ -0,0 +1,91 @@ +wrapper = $wrapper; + } + + public function persist(): void { + $this->wrapper->store($this); + $this->setPersisted(); + } + + /** + * @return RecipeEntityImpl + */ + public function getRecipe(): RecipeEntityImpl { + return $this->recipe; + } + + /** + * @return KeywordEntityImpl + */ + public function getKeyword(): KeywordEntityImpl { + return $this->keyword; + } + + /** + * @param RecipeEntityImpl $recipe + */ + public function setRecipe(RecipeEntityImpl $recipe) { + $this->recipe = $recipe; + } + + /** + * @param KeywordEntityImpl $keyword + */ + public function setKeyword(KeywordEntityImpl $keyword) { + $this->keyword = $keyword; + } + + /** + * {@inheritDoc} + * @see \OCA\Cookbook\Entity\impl\AbstractEntity::clone() + * @return KeywordMappingEntityImpl + */ + public function clone(): AbstractEntity { + $ret = $this->wrapper->createEntity(); + + $ret->setKeyword($this->keyword); + $ret->setRecipe($this->recipe); + + if ($this->isPersisted()) { + $ret->setPersisted(); + } + + return $ret; + } + + protected function equalsImpl(AbstractEntity $other): bool { + return $this->keyword->isSame($other->keyword) && $this->recipe->isSame($other->recipe); + } + + protected function isSameImpl(AbstractEntity $other): bool { + return $this->equalsImpl($other); + } + + public function remove(): void { + $this->wrapper->remove($this); + } +} diff --git a/lib/Entity/impl/RecipeEntityImpl.php b/lib/Entity/impl/RecipeEntityImpl.php new file mode 100644 index 000000000..972a9c5e4 --- /dev/null +++ b/lib/Entity/impl/RecipeEntityImpl.php @@ -0,0 +1,273 @@ +wrapper = $wrapper; + + $this->newCategory = null; + $this->setNewCategory = false; + $this->newKeywords = []; + $this->removedKeywords = []; + } + + public function persist(): void { + $this->wrapper->store($this); + $this->setPersisted(); + + $this->newCategory = null; + $this->newKeywords = []; + $this->removedKeywords = []; + } + + /** + * Obtain the id of the recipe in the database. + * @return number The id of the recipe in the database + */ + public function getId(): int { + return $this->id; + } + + /** + * Get the name of the recipe + * @return string The name of the recipe + */ + public function getName(): string { + return $this->name; + } + + /** + * Set the id of the recipe in the database. + * Caution: This should be done only by the corresponding wrapper after inserting into the DB + * @param number $id The new id + */ + public function setId($id): void { + $this->id = $id; + } + + /** + * Set the name of the recipe + * @param string $name The new name of the recipe + */ + public function setName($name): void { + $this->name = $name; + } + + /** + * @return CategoryEntityImpl + */ + public function getCategory(): CategoryEntity { + return $this->setNewCategory ? $this->newCategory : $this->wrapper->getCategory($this); + } + + /** + * @param CategoryEntity $category + */ + public function setCategory(CategoryEntity $category): void { + $this->newCategory = $category; + $this->setNewCategory = true; + } + + public function remove(): void { + $this->wrapper->remove($this); + } + + protected function equalsImpl(AbstractEntity $other): bool { + if (! $this->isSame($other)) { + return false; + } + + // Compare internal structures + if ($this->name !== $other->name) { + return false; + } + + // FIXME Compare references as well? + } + + public function getKeywords(): array { + $keywords = $this->wrapper->getKeywords($this); + + // Filter out all removed keywords + foreach ($this->removedKeywords as $rkw) { + $keywords = $this->filterOutKeyword($keywords, $rkw); + } + + // Add newly added keywords + $keywords = array_merge($keywords, $this->newKeywords); + + return $keywords; + } + + public function removeKeyword(KeywordEntity $keyword): void { + if ($this->isKeywordInList($keyword, $this->removedKeywords)) { + // The keyword is already mentioned as to be removed. + return; + } + + if ($this->isKeywordInList($keyword, $this->newKeywords)) { + // We recently added the keyword. Remove it simply from the adding list + $this->newKeywords = $this->filterOutKeyword($this->newKeywords, $keyword); + } else { + $dbKeywords = $this->wrapper->getKeywords($this); + + if ($this->isKeywordInList($keyword, $dbKeywords)) { + // It is present in the DB, remove it + $this->removedKeywords[] = $keyword; + } else { + // We have the keyword not in the DB. + throw new EntityNotFoundException($this->l->t('Cannot remove a keyword not been assigned to recipe.')); + } + } + } + + public function reload(): void { + // FIXME + } + + public function addKeyword(KeywordEntity $keyword): void { + if ($this->isKeywordInList($keyword, $this->removedKeywords)) { + // If we should remove the keyword previously, just drop the removal + $this->removedKeywords = $this->filterOutKeyword($this->removedKeywords, $keyword); + return; + } + + $dbKeywords = $this->wrapper->getKeywords($this); + if ($this->isKeywordInList($keyword, $dbKeywords)) { + // throw new InvalidDbStateException($this->l->t('Cannot add a keyword multiple times.')); + return; + // XXX Better silent ignorance or exception + } + + $this->newKeywords[] = $keyword; + } + + protected function isSameImpl(AbstractEntity $other): bool { + return $this->id == $other->id; + } + + /** + * {@inheritDoc} + * @see \OCA\Cookbook\Entity\impl\AbstractEntity::clone() + * @return RecipeEntityImpl + */ + public function clone(): AbstractEntity { + $ret = $this->wrapper->createEntity(); + + $ret->id = $this->id; + $ret->name = $this->name; + $ret->newCategory = $this->newCategory; + $ret->newKeywords = $this->newKeywords; + $ret->removedKeywords = $this->removedKeywords; + + if ($this->isPersisted()) { + $ret->setPersisted(); + } + + return $ret; + } + + /** + * @param KeywordEntityImpl[] $keywords + * @param KeywordEntityImpl $list + * @return KeywordEntityImpl[] + */ + private function filterOutKeyword(array $list, KeywordEntityImpl $keyword): array { + return array_filter($list, function ($kw) use ($keyword) { + if ($keyword->equals($kw)) { + return false; + } + return true; + }); + } + + private function isKeywordInList(KeywordEntityImpl $keyword, array $list): bool { + $oldNumber = count($list); + $list = $this->filterOutKeyword($list, $keyword); + $newNumber = count($list); + + return $oldNumber != $newNumber; + } + + /** + * @return ?CategoryEntityImpl + */ + public function getNewCategory(): ?CategoryEntityImpl { + return $this->newCategory; + } + + /** + * @return KeywordEntityImpl[] + */ + public function getNewKeywords(): array { + return $this->newKeywords; + } + + /** + * @return KeywordEntityImpl[] + */ + public function getRemovedKeywords(): array { + return $this->removedKeywords; + } + + /** + * @return boolean + */ + public function newCategoryWasSet() { + return $this->setNewCategory; + } +} diff --git a/lib/Exception/EntityNotFoundException.php b/lib/Exception/EntityNotFoundException.php new file mode 100644 index 000000000..be1efcba8 --- /dev/null +++ b/lib/Exception/EntityNotFoundException.php @@ -0,0 +1,13 @@ +