Skip to content

feat: introducing resource views#45

Draft
sitepark-becker wants to merge 13 commits intomainfrom
feature/resource-views
Draft

feat: introducing resource views#45
sitepark-becker wants to merge 13 commits intomainfrom
feature/resource-views

Conversation

@sitepark-becker
Copy link
Contributor

@sitepark-becker sitepark-becker commented Sep 22, 2025

Introduces resource views as described in issue #44

The suggested ResourceResolver interface was renamed to ResourceViewContributor.

Additionally, the following classes/features were added:

ResourceViewManager

The ResourceViewManager acts as an high-level entry point for retrieving ResourceViews. For now, this is pretty much just a wrapper for ResourceViewFactory, which additionally caches the ResourceView objects for each resource.

Batched ResourceView creation for better performance

ResourceViewContributors can now optionally implement the interface ResourceViewBatchContributor to define an optimized process for creating multiple resource views at once. In some cases, implementing this method can lead to significant performance gains, e.g. when a contributor has to make an expensive call to an API or a database.
Here's a quick example:

final class AuthoInfoContributor implements ResourceViewContributor, ResourceViewBatchContributor {
   ...
   public function batchContribute(array $resources, array $builders): void {
        $ids = array_map(fn($r) => $r->id, $resources);
        $rows = $this->db->query(
            'SELECT * FROM authors WHERE article_id IN (?)',
            [$ids]
        );
        foreach ($rows as $row) {
            $builders[$row['article_id']]->add(
                AuthorInfoFeature::class,
                fn() => new AuthorInfoFeature($row)
            );
        }
    }
}

Lazy-Loading resource features

ResourceView now supports lazy feature initialization. Instead of passing the already initialized feature objects to the ResourceView, it can now also handle callables.
So instead of doing this

// passing the object directly
$resourceViewBulder->add(SomeFeature::class, new SomeFeature(...));

we can do

// passing the object via callback
$resourceViewBulder->add(SomeFeature::class, fn() => new SomeFeature(...));
// or
$resourceViewBulder->add(SomeFeature::class, $someCallbackThatIsCalledLazily);

I also added ResourceView::preloadAll() as a way to resolve all features eagerly.

However, there are also cons to lazy-loading. Here's what ChatGPT says about this implementation. 😄

@sitepark-becker sitepark-becker self-assigned this Sep 22, 2025
Copy link
Member

@sitepark-veltrup sitepark-veltrup left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still not entirely comfortable with AbstractResource.

For me, Resource is not a Sitekit resource but a universal resource container.

The obectType concept is also general, as it allows the type of resource to be identified depending on where it comes from.

$dataBag is the content of the loaded resource.

What I didn't want is for us to have various resource classes again that have to be derived from Resource (now ResourceAbstract).

I think that's no longer necessary now that we have the features.

But maybe I haven't quite understood yet how you want to use AbstractResource.

@codecov
Copy link

codecov bot commented Sep 23, 2025

Codecov Report

❌ Patch coverage is 3.33333% with 145 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.03%. Comparing base (473938f) to head (1ed4e69).

Files with missing lines Patch % Lines
src/ResourceView.php 0.00% 29 Missing ⚠️
src/ResourceViewFactory.php 0.00% 25 Missing ⚠️
src/ResourceViewManager.php 0.00% 19 Missing ⚠️
src/ResourceViewBuilder.php 0.00% 12 Missing ⚠️
src/SiteKit/SiteKitResource.php 0.00% 10 Missing ⚠️
src/Exception/MissingFeatureException.php 0.00% 5 Missing ⚠️
src/ResourceFeature/SeoFeature.php 0.00% 4 Missing ⚠️
src/Model/GeoJson.php 0.00% 2 Missing ⚠️
src/Model/Image/Image.php 0.00% 2 Missing ⚠️
src/Model/Image/ImageSource.php 0.00% 2 Missing ⚠️
... and 18 more
Additional details and impacted files
@@              Coverage Diff               @@
##                main      #45       +/-   ##
==============================================
- Coverage     100.00%   82.03%   -17.97%     
- Complexity       198      262       +64     
==============================================
  Files             16       44       +28     
  Lines            706      807      +101     
==============================================
- Hits             706      662       -44     
- Misses             0      145      +145     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sitepark-becker
Copy link
Contributor Author

I'm still not entirely comfortable with AbstractResource.

I removed AbstractResource again and instead marked several properties of Resource as deprecated. I also added a class SiteKitResource that can be used instead if the underlying resource originated from our CMS. We should probably move this class into a separate bundle though. For now, I'll leave it as.

@sitepark-veltrup
Copy link
Member

Yes, I think that's fine. Are there any tests coming up?

@sitepark-veltrup
Copy link
Member

sitepark-veltrup commented Sep 24, 2025

Perhaps SiteKitResource could already be used here?

@sitepark-becker
Copy link
Contributor Author

sitepark-becker commented Oct 6, 2025

Ok, I think I need some feedback before going any further because I'm not sure If Im moving in the right direction.

I added some basic resource features (located under Atoolo\Resource\ResourceFeature) that, I think, one would expect "out of the box". Notice that I did not fully implement every feature/model yet.
I also added a namespace Atoolo\Resource\SiteKit and added a contributor for each feature to support sitekit resources. This namespace should definitely be moved into a new bundle, I only added them for testing purposes.

I think there's much to discuss here which we should probably do in person.

@sitepark-becker
Copy link
Contributor Author

Current state overview

The ResourceView system is implemented as discussed. Here's a short recap:

  • ResourceFeature: final, readonly data container class representing a single concept/responsibility/capability of a resource, e.g. TeaserFeature or SeoFeature
  • ResourceView: Holds all ResourceFeatures of a given Resource. ResourceFeatures should only be accessed through this class. By default, features are lazy-loaded when accessing them.
  • ResourceViewFactory: factory that creates a ResourceView of a given Resource. It uses ResourceViewContributors to resolve the ResourceFeatures of a resource.
  • ResourceViewManager: High-level entry point for retrieving ResourceViews. Essentially, its a wrapper for the ResourceViewFactory but additionally caches the `ResourceView´.

Quick example

// $this->resourceViewManager is injected using DI
// create a resource view of a given resource $resource
$resourceView = $this->resourceViewManager->forResource($resource); 

// access teaser data
$teaserFeature = $resourceView->get(TeaserFeature::class);

So the basic services for this resource view system is already implemented, but feel free to suggest changes.

Next steps

I think this bundle should provide a bunch of basic ResourceFeatures that one would expect "out of the box".
For that, I searched through all atoolo bundles to find some commong use-cases. Basically I searched for all lines where $resource->data->get... is called and evaluated how this could be translated into a ResourceFeature.

Here are the ResourceFeatures I came up with. They are sufficient to cover all basic use-cases, but are still very much WIP. They are located under src/ResourceFeature;

  • SeoFeature: Basic metadata/seo data like "title", "description" - (maybe MetadataFeature?)
  • TeaserFeature: Basic teaser data
  • LinkFeature: Provides a html-renderable link that leads to the underlying resource
  • MediaMetadataFeature: Sometimes, resources are metadata of a media file (.meta.php). I this case, this feature provides the metadata of that file
  • TaxonomyFeature: Provides access to taxonomies associated with a resource, such as categories, tags, or keywords.
  • ContentFeature: basic content data of a resource like headline, intro, kicker
  • GeoFeature: geospacial information of a resource
  • SiteKitMetadataFeature: Provides metadata that is sitekit specific, like anchor and name (CMS name) etc. This should be moved into a separate atoolo-sitekit-bundle, along with the other sitekit specific classes.

Then there are some more features that the AI suggested, but are technically not needed yet:

  • IdentityFeature: Provides basic identity information like a globally unique id and url
  • PublishingDataFeature: Provides data about the lifecycle dates like createdAt, updatedAt and publishedAt
  • OpenGraphFeature: open graph data of a resource

Some bundles will also need additional features. For example, the atoolo-events-calendar-bundle probably requires a SchedulingFeature that provides scheduling data, but this should probably be provided by the events calendar bundle itself.

I also couldn't help but implement some basic models like Image or Link. (located under src/Models, also still WIP).

The implementation of both the ResourceFeatures as well as the models is very important to get right from the start and there's much room for debate here, so feel free to share you thoughts.

@sitepark-schaeper @Schleuse @hyra-soe @sitepark-asc

Copy link
Member

@sitepark-schaeper sitepark-schaeper left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My initial thoughts were:

  • What is the advantage of separating ResourceViewFactory and ResourceViewManager? In my opinion the factory can just as well cache the views.

  • Shouldn't the ResourceViewBatchContributor extend ResourceViewContributor? I cannot imagine a case where a class would want to implement the batch-contributor but not the "normal" one.

  • It's weird that the ResourceViewFactory delegates to ResourceViewBatchContributors via two separate arrays of Resources and ResourceViewBuilders. Maybe wrap them in a ResourceContributionBatch or something?

  • I'm not sure about the name "Contributor". I know this is a draft, but it did throw me off. ResourceViewEnricher? ResourceFeatureFactory? maybe even ResourceViewBuilderDecorator? (I know this is technically not a decorator..)

But now that I've played around with this I think I've got slightly more profound feedback:

I found implementing contributors to be somewhat awkward. They have to process the resource's data twice and even though the second time it should be valid they still have to handle the case where it isn't:

class ExampleLinkContributor implements ResourceViewContributor
{
    #[\Override]
    public function supports(Resource $r): bool
    {
        return $this->getLinkData($r) !== false;
    }

    #[\Override]
    public function contribute(Resource $r, ResourceViewBuilder $b): void
    {
        [$url, $label, $isExternal] = $this->getLinkData($r)
            ?: throw new \Exception('invalid data');
        $b->add(
            LinkFeature::class,
            new LinkFeature(
                new Link(
                    url: $url,
                    label: $label,
                    isExternal: $isExternal,
                ),
            ),
        );
    }

    /** @return list{string, string, bool}|false */
    private function getLinkData(Resource $r): array|false
    {
        $link = $r->data->getArray('link');
        $url = $link['url'] ?? null;
        $label = $link['label'] ?? null;
        $isExternal = $link['external'] ?? false;
        if (!is_string($url) || !is_string($label) || !is_bool($isExternal)) {
            return false;
        }
        return [$url, $label, $isExternal];
    }
}

This is especially annoying since PHP has no checked exceptions and it's hard to know/enforce where and which may be thrown. Take, for example, a contributor that uses a database:

class ExampleDbContributor implements ResourceViewContributor, ResourceViewBatchContributor
{
    #[\Override]
    public function supports(Resource $r): bool
    {
        return $this->isAppropriateResource($r)
            && $this->hasCorrectDataInDb($r); // might throw
    }

    /**
     * @param list<Resource> $resources
     * @param array<string, ResourceViewBuilder> $builders
     */
    #[\Override]
    public function contributeBatch(array $resources, array $builders): void
    {
        $queryBuilder = new QueryBuilder();
        foreach ($resources as $resource) {
            if (!$this->isAppropriateResource($resource)) {
                throw new \Exception('invalid resource');
            }
            $queryBuilder->add($resource);
        }
        $links = $this->createAllFromDb($queryBuilder->build()); // can throw
        foreach ($links as $id => $link) {
            $builders[$id]->add(
                LinkFeature::class,
                fn() => new LinkFeature(
                    $this->fetchFrom($link), // can throw
                ),
            );
        }
    }

    // ...
}

Now, the fetchFrom may be exaggerated, but I hope you can see that there may be a lot of places where exceptions could be thrown. And this is not only irritating for the implementation:

public function printLink(Resource $resource): void
{
    try {
        $view = $this->manager->forResource($resource);
    } catch (\Exception $exception) {
        echo 'invalid resource';
        return;
    }

    try {
        $link = $view->get(LinkFeature::class);
    } catch (MissingFeatureException $exception) {
        echo 'resource has no link';
        return;
    } catch (\Exception $exception) {
        echo 'could not resolve resource data';
        return;
    }

    echo "<a href=\"{$link->link->url}\">{$link->link->label}</a>";
}

I feel like we can remove the supports check:

class ExampleLinkContributor implements ResourceViewContributor
{
    #[\Override]
    public function tryContribute(Resource $r, ResourceViewBuilder $b): void
    {
        $link = $r->data->getArray('link');
        $url = $link['url'] ?? null;
        $label = $link['label'] ?? null;
        $isExternal = $link['external'] ?? false;
        if (!is_string($url) || !is_string($label) || !is_bool($isExternal)) {
            // alternatively return false but it does not seem to be
            // relevant to know whether a contributor actually contributed.
            return;
        }
        $link = new Link(url: $url, label: $label, isExternal: $isExternal);
        $b->add(LinkFeature::class, new LinkFeature($link));
    }
}

This also improves the ResourceViewFactory:

 public function createViews(array $resources): array
 {
     $builders = [];
     foreach ($resources as $r) {
         $builders[$r->id] = new ResourceViewBuilder();
     }
     foreach ($this->contributors as $contributor) {
-            $supportedResources = [];
-            $buildersForSupportedResources = [];
-            foreach ($resources as $r) {
-                if ($contributor->supports($r)) {
-                    $supportedResources[] = $r;
-                    $buildersForSupportedResources[$r->id] = $builders[$r->id];
-                }
-            }
-            if (empty($supportedResources)) {
-                continue;
-            }
         if ($contributor instanceof ResourceViewBatchContributor) {
-                $contributor->contributeBatch($supportedResources, $buildersForSupportedResources);
+                $contributor->contributeBatch($resources, $builders);
         } else {
-                foreach ($supportedResources as $r) {
-                    $contributor->contribute($r, $buildersForSupportedResources[$r->id]);
+                foreach ($resources as $r) {
+                    $contributor->tryContribute($r, $builders[$r->id]);
             }
         }
     }

(on a side note, we could also think about providing a separate BatchAwareViewFactory with this behaviour and make the current one just foreach ($resources as $r) { $result[$r->id] = $this->createView($r); } for when the batch loading isn't required)

What we could do, is to require contributors and the lazy-loading closures to return false/null instead of throwing (or catch them and then return null/false). We would not need the try-catches and could just do this:

    $link = $manager->forResource($resource)->tryGet(LinkFeature::class)
        ?? throw new \Exception('resource has no link');

An issue might be, that we cannot differentiate between

  • the resource is invalid
  • the resource has no link
  • the factory has no link-contributor
  • the link-contributor failed
  • the lazy-loading failed

That probably is irrelevant at runtime, but might make it harder to set this up or track down bugs.

The "loading-from-database"-example also revealed a couple things to me:
If I have my resource data in a database or on another server, I probably would want the contributor(s) to lazy-load. However, the actual options to achieve that seem lackluster:

class ExampleDbContributor implements ResourceViewBatchContributor
{
    /**
     * @param list<Resource> $resources
     * @param array<string, ResourceViewBuilder> $builders
     */
    #[\Override]
    public function tryContributeBatch(array $resources, array $builders): void
    {
        // either load everything lazily but requiring a db call each time
        for ($i = count($resources) - 1; $i >= 0; $i--) {
            $resource = $resources[$i];
            $builder = $builders[$i];
            $builder->add(
                LinkFeature::class,
                fn () => new LinkFeature($this->fetchLinkFromDb($resource)),
            );
            $builder->add(
                SeoFeature::class,
                fn () => new SeoFeature($this->fetchSeoFromDb($resource)),
            );
            // ...
        }
        // this also has the issue of data possibly changing in the db in
        // between resolving
    }

    /**
     * @param list<Resource> $resources
     * @param array<string, ResourceViewBuilder> $builders
     */
    #[\Override]
    public function tryContributeBatch(array $resources, array $builders): void
    {
        // or always load everything all at once.
        // this may be feasible or even ideal, but that heavily depends on the
        // scenario in which it is used
        $results = $this->fetchFromDb($resources);
        foreach ($results as $result) {
            $builder = $builders[$result->id];
            $builder->add(LinkFeature::class, new LinkFeature($result->link));
            $builder->add(SeoFeature::class, new SeoFeature($result->seo));
            // ...
        }
    }
}

Regardless of the second option being improvable or not, the first one is certainly inferior. Which begs the question: What good is the lazy-loading feature really? It seems to only be useful in the specific scenario where I have exactly one expensive feature, which has to be resolved individually for each resource regardless (like an atomic rest api). Other than that I currently find it to only complicate things (like the error handling mentioned earlier).

If we want this, we would have to be able to lazy-load more than one feature at a time. Taking the previous example further, imagine the data for both, the LinkFeature and the TeaserFeature requiring a db call, while the SeoFeature creation is inexpensive.

    public function getSomeFeatures(): iterable
    {
        $viewA = $manager->forResource($this->resourceA);
        yield $viewA->tryGet(LinkFeature::class);   // load from db _once_
        yield $viewA->tryGet(SeoFeature::class);    // no db call required
        yield $viewA->tryGet(TeaserFeature::class); // has already been loaded

        // for these no db call should be made at all
        $viewB = $manager->forResource($this->resourceB);
        yield $viewB->tryGet(SeoFeature::class);
    }

Additionally, when batch-loading we have to be careful about:

  1. lazy-loading on a per-view basis as in the function above,
    because we might want to iterate them and would still cause a lot of requests
  2. lazy-loading all view-data of the entire batch,
    because we might only need a very small portion of that data while potentially loading lots
  3. not lazy-loading at all,
    because we might not even need any db data

All of this depends heavily on the usecase, meaning we either have to offer the flexibility for users to implement this mechanism however they need it (which sounds hard to achieve elegantly) or find a way to propagate the required features ahead of loading them.
The more I look into this, the more it seems like we'd want to re-implement graphql here:

    public function getSomeMoreFeatures(): iterable
    {
        // this could lazy-load the link- and teaser-features all together,
        // but would not have to since we probably need that data regardless.
        // (ignore the weird, imaginary method signature here..)
        [$viewA, $viewB, $viewC] = $manager->queryForResources(
            [$this->resourceA, [LinkFeature::class, SeoFeature::class]],
            [$this->resourceB, [LinkFeature::class, TeaserFeature::class]],
            [$this->resourceC, [TeaserFeature::class]],
        );
    }

Depending on how we intend to use this, we may want to separate the Resource and their associated data.
A Resource would only need an id and language (or whatever uniquely identifies it) and we'd have a ResourceRepository which could hold (cache) their data. Here's a quick mock up:

class SimpleRepository implements ResourceRepository
{
    /**
     * @var WeakMap<
     *   string,
     *   array<class-string<ResourceFeature>, ResourceFeature|false>
     * > $data
     */
    private WeakMap $data;

    /**
     * @param list<list{string, list<class-string<ResourceFeature>>}> $query
     * @return list<ResourceView>
     */
    #[\Override]
    public function view(array $query): array
    {
        $query = ResourceViewQuery::atomic($query);
        return $this->resolveQuery($query);
    }

    /**
     * @param list<string> $resources
     * @param list<class-string<ResourceFeature>> $features
     * @return list<ResourceView>
     */
    #[\Override]
    public function viewAll(array $resources, array $features): array
    {
        $query = ResourceViewQuery::builder()
            ->resources($resources)
            ->features($features);
        return $this->resolveQuery($query);
    }

    /**
     * @return list<ResourceView>
     */
    private function resolveQuery(ResourceViewQuery $query): array
    {
        $context = new ResourceLoadingContext($query);
        try {
            foreach ($query->fields as $field) {
                if (isset($this->data[$field->resource->id])) {
                }
                $features = $this->data[$field->resource->id];
                if ($features === false) {
                    // we already tried to load this resource and failed
                    // ...
                }
                $feature = $features[$field->featureClass] ?? null;
                if ($feature !== null) {
                    $query->resolve($field->resource->id, $feature);
                    continue;
                }
                $this->loaders->delegate($field, $context);
            }
            $this->cache($this->loaders->load($context));
            return $query->unpack();
        } catch (IncompletelyResolvedQueryException $exception) {
            // ...
        } catch (\Exception $exception) {
            // ...
        }
    }

    /** 
     * @param iterable<array<
     *   string,
     *   array<class-string<ResourceFeature>, ResourceFeature|false>
     * >>
     */
    private function cache(iterable $data): void
    {
        foreach ($data as $entry) {
            foreach ($entry as $id => $values) {
                $this->data[$id] = [...($this->data[$id] ?? []), ...$values];
            }
        }
    }
}
class DefaultResourceLoaders implements ResourceLoaders
{
    /** @var array<class-string<ResourceFeature>, list<ResourceLoader>> $loaders */
    private array $loaders;

    #[\Override]
    public function delegate(
        ResourceViewQueryField $field,
        ResourceLoadingContext $context,
    ): void {
        $loaders = $this->loaders[$field->featureClass]
            ?? throw new \Exception('no appropriate loader');
        foreach ($loaders as $loader) {
            if ($loader->tryAddField($field, $context)) {
                return;
            }
        }
        throw new \Exception('no appropriate loader');
    }

    /**
     * @return iterable<array<
     *   string,
     *   array<class-string<ResourceFeature>, ResourceFeature|false>
     * >>
     */
    #[\Override]
    public function load(ResourceLoadingContext $context): iterable
    {
        foreach ($context->getActiveLoaders() as $loader) {
            // should probably not be iterable as all items have to be consumed
            yield $loader->load($context);
        }
    }
}
class ExampleDbResourceLoader implements ResourceLoader
{
    /** @var array<class-string<ResourceFeature>, list<ResourceLoader>> $loaders */
    private array $loaders;

    #[\Override]
    public function tryAddField(
        ResourceViewQueryField $field,
        ResourceLoadingContext $context,
    ): bool {
        if (!($field->resource instanceof SpecialResource)) {
            return false;
        }
        $fields = $this->fieldsForFeature($field->featureClass);
        if ($fields === false) {
            return false;
        }
        $context->forLoader($this)->merge(
            [
                $field->resource->id => [
                    'fields' => $fields,
                    'features' => [$field->featureClass],
                ],
            ],
        );
        return true;
    }

    /**
     * @return array<
     *   string,
     *   array<class-string<ResourceFeature>, ResourceFeature|false>
     * >
     */
    #[\Override]
    public function load(ResourceLoadingContext $context): array
    {
        $data = $context->forLoader($this)->get();
        $statements = ['BEGIN TRANSACTION;'];
        foreach ($data as $id => $properties) {
            $statements[] = 'SELECT id, ' . implode(', ', $properties['fields'])
                . " FROM Resource WHERE id == '$id';";
        }
        $statements[] = 'COMMIT TRANSACTION;';
        $resultSet = $this->db->executeStatements($statements);
        $loaded = [];
        foreach ($resultSet->rows() as $row) {
            foreach ($data[$row->id]['features'] as $featureClass) {
                $feature = $this->createFeature($featureClass, $row);
                $loaded[$row->id][$featureClass] = $feature;
                $context->loaded($row->id, $feature);
            }
        }
        return $loaded;
    }

    /** @param class-string<ResourceFeature> $featureClass */
    private function fieldsForFeature(string $featureClass): array
    {
        return match ($featureClass) {
            LinkFeature::class => ['url', 'label', 'is_external'],
            TeaserFeature::class => ['url', 'headline', 'image_url'],
            default => false,
        };
    }

    /**
     * @template T of ResourceFeature
     * @param class-string<T> $featureClass
     * @return T
     */
    private function createFeature(
        string $featureClass,
        SqlResultSetRow $row,
    ): ResourceFeature {
        return match ($featureClass) {
            LinkFeature::class => new LinkFeature(
                url: $row->url,
                label: $row->label,
                external: $row->is_external,
            ),
            TeaserFeature::class => new TeaserFeature(
                url: $row->url,
                headline: $row->headline,
                image: $row->image_url,
            ),
            default => throw new \Exception(
                'encountered unexpected feature class',
            ),
        };
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants