File: D:/HostingSpaces/slenders/slenders.nl/app/Komma/Shop/Catalog/Kms/CatalogService.php
<?php
namespace App\Komma\Shop\Catalog\Kms;
use App\Komma\Kms\JsonApi\JsonApiLengthAwarePaginator;
use App\Komma\Kms\JsonApi\TranslatableResource;
use App\Komma\Kms\Core\AbstractTranslatableModel;
use App\Komma\Kms\Core\AbstractTranslationModel;
use App\Komma\Kms\Core\Sections\SectionService;
use App\Komma\Kms\Core\Sections\SectionServiceInterface;
use App\Komma\Globalization\Languages\Models\Language;
use App\Komma\Shop\Categories\Kms\CategoryServiceInterface;
use App\Komma\Shop\Categories\Models\Category;
use App\Komma\Shop\Categories\Models\CategoryTranslation;
use App\Komma\Shop\Products\Product\Product;
use App\Komma\Shop\Products\Product\ProductServiceInterface;
use App\Komma\Shop\Products\Product\ProductTranslation;
use App\Komma\Shop\Products\ProductableInterface;
use App\Komma\Shop\Products\ProductComposite\ProductComposite;
use App\Komma\Shop\Products\ProductComposite\ProductCompositeServiceInterface;
use App\Komma\Shop\Products\ProductComposite\ProductCompositeTranslation;
use App\Komma\Shop\Products\ProductGroup\ProductGroup;
use App\Komma\Shop\Products\ProductGroup\ProductGroupServiceInterface;
use App\Komma\Shop\Products\ProductGroup\ProductGroupTranslation;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Request;
/**
* Builds the product catalog for the frontend. And has methods for searching trough it.
* Notice that searching will be done via laravel scout and not via the catalog_index table.
* That table is just being used for displaying the catalog
*
* Class CatalogService
* @package App\Komma\Shop\Catalog
*/
class CatalogService extends SectionService implements CatalogServiceInterface
{
/**
* @var string[] Attributes that can be indexed / sorted. Used for creating the migration. Make sure you also reflect these variables in the CatalogIndexModel
*/
private $attributes;
/**
* @var SectionServiceInterface[] SectionService extensions whom forModels can be searched trough. If they also are productables they will be put in they will be put in the catalog.
* For example, The productServiceInterfaces forModel is called Product. This Product also is a productable so it will be shown in the catalog (and indexed in the catalog_index table).
* And it is searchable because the Product model uses laravels scout Searchable trait.
*
* But the CategoryServiceInterface forModel is called Category and is not a productable. Therefore it won't be indexed. But because it also has the Searchable trait from laravels scout,
* it will be searchable.
*/
private $catalogableServicesInterfaces = [];
/**
* The models that can be searched trough. They are recognized because they use the Searchable trait
*
* @var array
*/
public static $searchableModelClasses = [
Product::class,
ProductTranslation::class,
ProductGroup::class,
ProductGroupTranslation::class,
ProductComposite::class,
ProductCompositeTranslation::class,
Category::class,
CategoryTranslation::class
];
/**
* CatalogService constructor
*/
public function __construct()
{
$this->forModelName = CatalogIndexModel::class;
$this->catalogableServicesInterfaces[] = ProductServiceInterface::class;
$this->catalogableServicesInterfaces[] = ProductGroupServiceInterface::class;
$this->catalogableServicesInterfaces[] = CategoryServiceInterface::class;
$this->catalogableServicesInterfaces[] = ProductCompositeServiceInterface::class;
$this->attributes = [
'title' => 'string',
'price' => 'integer'
];
parent::__construct();
}
/**
* Returns a Query builder which holds a query which returns
* all catalogIndexModels with translations, documents and categories
*
* @return \Illuminate\Database\Eloquent\Builder|static
*/
public function itemsQueryWithRelations()
{
$allItemsQuery = CatalogIndexModel::with([
'catalogable' => function (MorphTo $query) {
$query->with([
'translation',
'documents',
'categories' => function ($query) {
$query->with('translations');
}
]);
}
]);
return $allItemsQuery;
}
/**
* Generate the model index that can be searched trough, paginated, sorted etc.
* It will use the attributes keys as attribute names on the models retrieved from all catalogableServiceInterface implementations.
* These attribute keys will be set with their values on the CatalogIndexModel that wil be saved to the database.
* If the catalog model does not have one of the attributes that are specified in the attributes value it will
*/
public function createIndex()
{
$this->doHouseKeeping();
\Log::debug('CatalogService indexing...');
$this->validateCatalogIndexModelDefinition();
foreach ($this->catalogableServicesInterfaces as $catalogableServiceInterface) {
/** @var SectionServiceInterface $service */
$service = app($catalogableServiceInterface);
$modelCollection = $service->models()->get();
if(!$modelCollection) continue;
$modelCollection->each(function ($model) use ($service) {
if (!is_a($model, ProductableInterface::class)) {
return;
}
$catalogIndexModel = $this->forModelName::firstOrNew([
'catalogable_id' => $model->id,
'catalogable_type' => $service->getForModelName()
]);
/** @var $model Model */
foreach ($this->attributes as $attribute => $type) {
if ($this->modelHasAttribute($model, $attribute)) {
$catalogIndexModel->{$attribute} = $model->$attribute;
$catalogIndexModel->catalogable_id = $model->id;
$catalogIndexModel->catalogable_type = $service->getForModelName();
}
}
$catalogIndexModel->save();
});
}
\Log::debug('CatalogService indexing done!');
}
/**
* Clear the catalog.
*/
public function clearIndex()
{
\Log::debug('CatalogService clearing index...');
$this->forModelName::truncate();
\Log::debug('CatalogService cleared index!');
}
/**
* Clean up old catalog index items that reference non existing models.
*/
public function doHouseKeeping()
{
\Log::debug('CatalogService doing some housekeeping...');
$debugCounter = 0;
/** @var CatalogIndexModel[] $indexModels */
$indexModels = $this->forModelName::get(['id', 'catalogable_type', 'catalogable_id']);
$indexModels->each(function ($indexModel) use (&$debugCounter) {
/** @var CatalogIndexModel $indexModel */
//If the catalogable_type class does not exist we need to delete it.
if (!class_exists($indexModel->catalogable_type)) {
$indexModel->delete();
return; //skip to the next "each" iteration
}
// if($debugCounter == 1) dd($indexModel->catalogable_type::where('id', '=', $indexModel->catalogable_id)->get()); //debugging helper
//Check if there is a catalogable_type class with the id specified. If not delete the indexModel
if ($indexModel->catalogable_type::where('id', '=', $indexModel->catalogable_id)->get()->count() == 0) {
$indexModel->delete();
}
$debugCounter++;
});
\Log::debug('CatalogService done housekeeping!');
}
/**
* Checks if all attributes defined in this service are also attributes on the CatalogIndexModel
*/
private function validateCatalogIndexModelDefinition()
{
$testModel = new $this->forModelName;
foreach ($this->attributes as $attribute => $type) {
if (!$this->modelHasAttribute($testModel, $attribute)) {
throw new \RuntimeException('The "' . $this->forModelName . '" class did not have the "' . $attribute . '" attribute. Make sure it exists in the model and in the schema table (' . $testModel->getTable() . ') it belongs to');
}
}
}
/**
* Checks is an eloquent model has the specified attribute or attributes and return true if it has the attribute(s) or false if not
*
* @param Model $model
* @param $attributeName
* @return bool
*/
private function modelHasAttribute(Model $model, $attributeName): bool
{
if (!is_string($attributeName) && !is_array($attributeName)) throw new \RuntimeException('The specified columnName isn\'t an array or a string');
if (!is_array($attributeName)) $attributeName = [$attributeName];
$hasAttribute = true;
foreach ($attributeName as $attribute) {
if (!\Schema::hasColumn($model->getTable(), $attribute)) $hasAttribute = false;
}
return $hasAttribute;
}
/**
* Create the catalog table. You can use this in your migrations
*/
public function migrate()
{
Schema::create((new $this->forModelName)->getTable(), function (Blueprint $table) {
$table->increments('id');
$table->string('catalogable_id');
$table->string('catalogable_type');
foreach ($this->attributes as $attribute => $type) {
$table->{$type}($attribute);
}
$table->timestamps();
});
}
/**
* Remove the catalog table. You can use this in your migrations
*/
public function rollback()
{
Schema::dropIfExists((new $this->forModelName)->getTable());
}
/**
* Returns CatalogIndexModels that are linked to a category that is specified by id.
* With the paginate option you can specify how much items should be visible per page/
*
* @param int $categoryID
* @param int|null $paginate
* @return \Illuminate\Database\Eloquent\Collection|LengthAwarePaginator
*/
public function getCatalogItemsByCategoryID(int $categoryID, int $paginate = null)
{
$allItems = CatalogIndexModel::all();
foreach ($allItems as $item) {
$type = $item->catalogable_type;
$id = $item->catalogable_id;
if($type == null) continue;
if($type !== Category::class) {
$item->item = $type::where('id', $id)->with([
'translation',
'documents',
'categories' => function ($query) {
$query->with('translations');
}
])->first();
} else {
$item->item = $type::where('id', $id)->with([
'translation',
'documents'
])->first();
}
}
$allItems = $allItems->filter(function ($value) use ($categoryID) {
if(!$value->item) return false;
return count($value->item->categories) > 0 && $value->item->categories[0]->id == $categoryID;
});
if (!empty($paginate)) {
//$allItems->paginate($paginate);
$page = 1;
$perPage = $paginate;
$allItems = new \Illuminate\Pagination\LengthAwarePaginator(
$allItems->forPage($page, $perPage),
$allItems->count(),
$perPage,
$page
);
}
return $allItems;
}
/**
* Search for term in the models from all catalogable services
*
* Returns 3 different things depending on how you specify parameters and call this method.
* 1. If you want to return paginated data as JSON. Set the page parameter and return the paginator returned by this method to the browser.
* 2. If you want to return data as Json. Not paginated. Don't set the page parameter and return the paginator returned by this method to the browser
* 3. If you want to return a paginator instance. Set the page parameter and don't return the paginator to the browser but use it yourself.
*
* @param Request $request
* @return JsonApiLengthAwarePaginator|\Illuminate\Contracts\View\View|JsonResponse|\Illuminate\Http\Resources\Json\AnonymousResourceCollection
*/
public function search(Request $request)
{
//Return the parameters from the request
$term = $request->input('term');
$currentPage = ($request->has('page')) ? $request->input('page') : null;
$perPage = ($request->has('amount')) ? $request->input('amount') : 10;
$language_id = $request->input('language_id');
if($language_id) {
$language = Language::find($language_id);
if($language) \App::setLanguage($language);
}
$paginate = ($currentPage) ? true : false;
//Validate the request
if (!$term) {
return new JsonResponse(["error" => "request should have a 'term' variable but did not have one."], 400);
}
//Make a collection that will hold al found models
$foundModels = collect();
//Loop over all defined services...
foreach ($this->catalogableServicesInterfaces as $catalogableServicesInterface) {
/** @var SectionServiceInterface $service */
$service = app($catalogableServicesInterface);
/** @var SectionServiceInterface $catalogableServicesInterface */
$forModelName = $service->getForModelName();
//Search models of the current service
if (method_exists($forModelName, 'search')) {
$models = $forModelName::search($term)->get();
$foundModels = $foundModels->merge($models);
}
//Search translation models of the current service
if (is_subclass_of($forModelName, AbstractTranslatableModel::class)) {
/** @var AbstractTranslatableModel $forModelName */
$translationModel = get_class((new $forModelName)->translations()->getRelated());
if (method_exists($translationModel, 'search')) {
$translationModels = $translationModel::search($term)->get();
/** @var Collection $foundModels */
$foundModels = $foundModels->merge($translationModels);
//Only use models which language id match the current language
$foundModels = $foundModels->filter(function(AbstractTranslationModel $abstractTranslationModel) use($language_id) {
return $abstractTranslationModel->language_id === $language_id;
});
}
}
}
//Convert all found models into TranslatableResources.
$resources = $foundModels->map(function (Model $model) use ($foundModels) {
$resource = null;
if (is_a($model, AbstractTranslationModel::class)) {
/** @var AbstractTranslationModel $model */
$translatable = $model->translatable()->first();
if ($translatable && $translatable->active == 1) {
$resource = new TranslatableResource($translatable);
}
} else if (is_a($model, AbstractTranslatableModel::class) && $model->active == 1) {
/** @var AbstractTranslatableModel $model */
$resource = new TranslatableResource($model);
}
return $resource;
})->filter(function ($value) {
return ($value !== null);
});
//Remove doubles
$uniqueResources = collect();
$resources->each(function (TranslatableResource $model) use ($uniqueResources) {
$found = false;
$uniqueResources->each(function (TranslatableResource $checkModel) use ($model, &$found) {
if ($model->resource->id == $checkModel->resource->id && get_class($model->resource) == get_class($checkModel->resource)) {
$found = true;
}
});
if ($found == false) {
$uniqueResources->push($model);
}
});
$paginatedResources = $this->limitTranslatableResourcesByResource($uniqueResources, $perPage, $currentPage);
//Return a json resource containing the results.
if ($request->ajax()) {
if ($paginate) {
$resourcesPaginator = new JsonApiLengthAwarePaginator(
$paginatedResources,
$paginatedResources->count(),
$perPage,
$currentPage, [
'path' => route('catalog.search')
]
);
return $resourcesPaginator;
} else {
return TranslatableResource::collection($uniqueResources);
}
} else {
abort(400, 'Only ajax requests are allowed.');
}
//TODO: Must be used in a frontend service
// else {
// $pageService = new PageService();
// $page = $pageService->getPageByCodeName('search');
// $links = $pageService->getAllTranslatedPageRoutes();
// $uniqueResources = $uniqueResources->sortBy(function ($resource) {
// return $resource->resource->lft;
// });
//
// return view('site.pages.search',[
// 'page' => $page,
// 'term' => $term,
// 'links' => $links,
// 'results' => $uniqueResources,
// ]);
// }
}
/**
* Paginates a collection of translatable resources. Per Resource class.
* So if your perPage variable is 5, currentPage variable is 1 and if you have 6 products and 1 category, you'll
* get the first 5 products AND the category in the resulting collection.
*
* @param Collection $translatableResources
* @param int $perPage
* @param int $currentPage
* @return Collection
*/
private function limitTranslatableResourcesByResource(Collection $translatableResources, int $perPage, int $currentPage, $flatten = true) {
//First order them by resource class name in separate collections
$orderedPerResourceClassName = collect();
$translatableResources->map(function (TranslatableResource $translatableResource) use(&$orderedPerResourceClassName, $perPage, $currentPage) {
$resourceClass = get_class($translatableResource->resource);
if(!$orderedPerResourceClassName->has($resourceClass)) $orderedPerResourceClassName->put($resourceClass, collect());
$orderedPerResourceClassName->get($resourceClass)->push($translatableResource);
});
//Then paginate each resource class collection
$orderedPerResourceClassNameAndPaginated = $orderedPerResourceClassName->map(function(Collection $translatableCollection, $translatableClassName) use ($perPage, $currentPage) {
$paginatedCollection = $translatableCollection->forPage($currentPage, $perPage);
return $paginatedCollection;
});
if($flatten) return $orderedPerResourceClassNameAndPaginated->flatten();
return $orderedPerResourceClassNameAndPaginated;
}
/**
* Clear the searchable data for scout
*/
public static function flushSearch()
{
foreach(self::$searchableModelClasses as $searchableModelClass)
{
\Artisan::call('scout:flush', ['model' => $searchableModelClass]);
}
}
/**
* Import searchable data for scout
*/
public static function importSearch()
{
foreach(self::$searchableModelClasses as $searchableModelClass)
{
\Artisan::call('scout:import', ['model' => $searchableModelClass]);
}
}
public static function reIndexSearch()
{
foreach(self::$searchableModelClasses as $searchableModelClass) {
/** @var Model $searchableModelClass */
$searchableModelClass::all()->each(function(Model $model) {
$model->touch(); //forces the save
$model->save();
});
}
}
/**
* Flushes the AbstractTranslationModels from the search datatabases where a certain attribute has a certain value on the AbstractTranslatableModel.
* If it does not equal the value, it will index the AbstractTranslationModels.
* Does this for all searchModel classes or a specific instance by delegating to method flushOrIndexTranslationSearchModelsFromTranslatableWhereModelAttributeEquals()
*
* @param string $attributeName
* @param int $attributeValue
* @param Model|null $model
* @see manageSearchabilityForTranslatableWhereModelWhere()
*/
public static function manageSearchabilityForGivenOrAllTranslatablesWhere(Model $model = null, $attributeName = 'active', $attributeValue = 0)
{
if(!$model) {
foreach (self::$searchableModelClasses as $modelClassName) {
$modelClassName::all()->each(function(Model $model) use ($attributeName, $attributeValue) {
self::manageSearchabilityForTranslatableWhereModelWhere($model, $attributeName, $attributeValue);
});
}
} else {
self::manageSearchabilityForTranslatableWhereModelWhere($model, $attributeName, $attributeValue);
}
}
/**
* Flushes the AbstractTranslationModels from the search datatabases where a certain attribute has a certain value on the AbstractTranslatableModel.
* If it does not equal the value, it will index the AbstractTranslationModels.
* Does this for a specific searchable model instance instance which is a AbstractTranslatableModel instance.
*
* @param string $attributeName
* @param int $attributeValue
* @param Model $model
*/
private static function manageSearchabilityForTranslatableWhereModelWhere(Model $model, $attributeName = 'active', $attributeValue = 0){
/** @var Model $model */
if(!is_a($model, AbstractTranslatableModel::class, true)) return;
if(!array_key_exists($attributeName, $model->attributesToArray())) return;
if($model->$attributeName == $attributeValue) {
/** @var AbstractTranslatableModel $model */
$model->translations()->get()->each(function (AbstractTranslationModel $abstractTranslationModel) use ($model, $attributeName, $attributeValue) {
if (is_callable([$abstractTranslationModel, 'unsearchable'], false) && method_exists($abstractTranslationModel, 'unsearchable')) {
$abstractTranslationModel->unsearchable();
// \Log::debug('Made ' . get_class($abstractTranslationModel) . ' instance with id ' . $abstractTranslationModel->id . ' unsearchable because the attribute ' . $attributeName . ' of class instance ' . get_class($model) . ' with id ' . $model->id . ' matched the expected ' . $attributeValue);
}
});
} else {
/** @var AbstractTranslatableModel $model */
$model->translations()->get()->each(function (AbstractTranslationModel $abstractTranslationModel) use ($model, $attributeName, $attributeValue) {
if (is_callable([$abstractTranslationModel, 'searchable'], false) && method_exists($abstractTranslationModel, 'unsearchable')) {
$abstractTranslationModel->searchable();
// \Log::debug('Made ' . get_class($abstractTranslationModel) . ' instance with id ' . $abstractTranslationModel->id . ' searchable because the attribute ' . $attributeName . ' of class instance ' . get_class($model) . ' with id ' . $model->id . ' did not match the expected ' . $attributeValue);
}
});
}
}
}