Skip to content

The pipeline: data mappers, mergers, normalizers

phil/content-bundle

Three tagged stages read and write the capabilities of a row. Each stage is one service per capability, and a collecting service runs them all.

StageInterfaceTagRun byDoes
1Application\Pipeline\DataMapperphil_content.data_mapperContentDataMapperwrites submitted data onto the unlocalized and localized draft rows
2Application\Pipeline\Mergerphil_content.mergerContentAggregator, DimensionContentCopiercopies one capability from a stored row onto a merged or copied row
3Application\Pipeline\Normalizerphil_content.normalizerContentNormalizerturns one capability of a merged row into plain data

The bundle tags every service implementing these interfaces by autoconfiguration, and tags its own services the same way, so the application's stages run beside the bundle's. The tag names are the constants PhilContentBundle::DATA_MAPPER_TAG, MERGER_TAG and NORMALIZER_TAG.

submitted data ──DataMapper──▶ draft rows (*, fr) ──Merger──▶ merged row ──Normalizer──▶ array
                                                    (aggregate)

Data mappers

interface DataMapper
{
    /** @param array<string, mixed> $data the submitted values, already validated by the application */
    public function map(DimensionContent $unlocalized, DimensionContent $localized, array $data): void;
}

ContentDataMapper::map($unlocalized, $localized, $data) runs every data mapper over the two draft rows of the dimension being edited. The caller loads or creates the rows and persists them afterwards; ContentEditor does both (publication.md). Each mapper handles one capability, ignores rows that lack it, and decides which of its values are shared and which are per locale. The order of the mappers does not matter, since no two write the same values. A value one mapper refuses stops the whole write before anything is persisted.

Submitted data

The keys are those of the normalized form. An absent key changes nothing; null clears its value, and a string that is empty once trimmed counts as null. That is what lets a form submit only the fields it shows. Strings are trimmed, except bodySource. A value of the wrong type is refused with InvalidContent, naming its key.

KeyAcceptedWritten onBy
templateKeya string or nullunlocalized rowTemplateDataMapper
templateDataa map of field names (strings) to values, or null for no field; replaced wholelocalized rowTemplateDataMapper
seoTitle, seoDescriptiona string or nulllocalized rowSeoDataMapper
seoKeywordsa list of strings, one comma-separated string, or null (the empty list); items trimmed, empty ones droppedlocalized rowSeoDataMapper
seoCanonicalUrla string (an absolute http/https URL) or nulllocalized rowSeoDataMapper
seoNoIndex, seoNoFollow, seoHideInSitemapa boolean or nulllocalized rowSeoDataMapper
excerptTitle, excerptDescriptiona string or nulllocalized rowExcerptDataMapper
excerptImagea string or nullunlocalized rowExcerptDataMapper
link{url}, {resourceKey, resourceId, locale?}, or null; an array naming both kinds, or neither, is refusedlocalized rowLinkDataMapper
sluga string or nulllocalized rowRoutableDataMapper
bodySourcea string, kept as written, or null; blank clears the bodylocalized rowBodyDataMapper
bodyFormatmarkdown (when absent), html, bbcode, mediawiki, dokuwiki, creole, text or ujo, the values of phil/markup's MarkupFormat, or the name of a format an add-on brings to the compiler; read only with bodySource; a format the compiler does not know, and a UJO source that is not a valid document, are refusedlocalized rowBodyDataMapper

There is no data mapper for WorkflowCapable: the place and the publication date are written by the workflow and the publisher, never by submitted data.

SubmittedData holds the readers the data mappers use — has(), string(), untrimmedString(), bool(), stringList(), map() — and an application data mapper may use them to follow the same rules.

Mergers

interface Merger
{
    public function merge(DimensionContent $target, DimensionContent $source): void;
}

ContentAggregator asks the root for a fresh row (createDimensionContent()), then runs every merger over the unlocalized row first and the localized row second, so a value the localized row sets wins over the shared one. Merging never writes onto a stored row. A merger copies only what its source actually sets, and ignores rows that lack its capability.

MergerRule
TemplateMergerthe key when the source sets one; the field values over the target's, key by key (array_replace, shallow)
WorkflowMergerthe place and the publication date, each when set
SeoMergereach value when set: a text or URL when not null, the keywords when not empty, a directive when true or false
ExcerptMergertitle, description and image, each when set
LinkMergerthe link, whole, when set
RoutableMergerthe slug, when set
BodyMergerthe body, whole, when set

DimensionContentCopier::copy(DimensionContent $source, DimensionKey $dimension) uses the same mergers to copy a row onto a fresh row of another dimension: publishing copies a draft row to its live twin, forking copies a version to the next. A fresh row has no value, so every capability that has a merger is copied whole; a capability without a merger is not copied, and is not read through the aggregator either.

Normalizers

interface Normalizer
{
    /** @return array<string, mixed> */
    public function normalize(DimensionContent $content): array;
}

ContentNormalizer::normalize($row) runs every normalizer over one merged row and combines their fragments key by key. Two normalizers writing the same key is refused with a LogicException rather than resolved by order: the second would silently hide the first, and which one wins would depend on the order of the services in the container. A normalizer returns an empty array for a row that lacks its capability.

The normalized form

KeyValueFrom
templateKeystring or nullTemplateNormalizer
templateDatathe field values as storedTemplateNormalizer
workflowPlaceunpublished, draft, published or nullWorkflowNormalizer
workflowPublishedRFC 3339 string with its offset, or nullWorkflowNormalizer
seoTitle, seoDescriptionstring or nullSeoNormalizer
seoKeywordslist of stringsSeoNormalizer
seoCanonicalUrlstring or nullSeoNormalizer
seoNoIndex, seoNoFollow, seoHideInSitemapboolean, false when no row set itSeoNormalizer
excerptTitle, excerptDescriptionstring or nullExcerptNormalizer
excerptImagethe reference as stored, or nullExcerptNormalizer
linknull, or {resourceKey, resourceId, locale, url} with the keys of the other kind set to nullLinkNormalizer
slugstring or nullRoutableNormalizer
bodySourcestring or null: the author's text on a draft row, the UJO JSON on a live rowBodyNormalizer
bodyFormata value of MarkupFormat on a draft row, ujo on a live row, or nullBodyNormalizer
bodyHtmlthe stored HTML purified again on this read, or nullBodyNormalizer
bodyRawHtmlwhether the author of the body held the raw HTML permission, or null without a body; a data mapper never reads it backBodyNormalizer

The normalized form is what a template receives as content, what ContentManager::draft() and live() return, and the shape the data mappers read back: a normalized link can be submitted as it is.

Example: reading a draft by hand

use Phil\ContentBundle\Domain\Dimension\{DimensionKey, Stage};
use Phil\LocaleDimensions\{Locale, Version};

$row = $aggregator->aggregate($root, DimensionKey::localized(Locale::fromString('fr'), Stage::Draft, Version::first()));
$data = $normalizer->normalize($row);   // ['excerptTitle' => '…', 'bodyHtml' => '…', …]

ContentManager::draft() and live() do this for you, picking the right version (content-manager.md).