umco / umbraco-ditto

Ditto - the friendly view-model mapper for Umbraco
http://our.umbraco.org/projects/developer-tools/ditto
MIT License
79 stars 33 forks source link

Mapping a polymorphic collection #168

Closed leekelleher closed 8 years ago

leekelleher commented 8 years ago

In issue #167, the topic of polymorphic collections came up, and would it be possible for DItto to map to them.

I've opened this as a separate thread so that we can discuss the concept, rather than implementation.

Do we think this is a good idea? Should we spend time to explore it?

As an example, I've put together an example of how it might work with Ditto.

This takes inspiration from @micklaw's Ditto Archetype Resolver feature for multiple-fieldsets.

public interface IModel { }

[DittoDocType]
public class MyModel : IModel { }

[DittoDocType("myDocType2")]
public class MyModel2 : IModel { }

[Test]
public void Polymorphic_Collection_Maps()
{
    var nodes = new List<PublishedContentMock>()
    {
        new PublishedContentMock { Id = 1111, DocumentTypeAlias = "myDocType1" },
        new PublishedContentMock { Id = 2222, DocumentTypeAlias = "myDocType2" },
        new PublishedContentMock { Id = 3333, DocumentTypeAlias = "myDocType3" },
    };

    var items = nodes.As<IEnumerable<IModel>>();

    Assert.That(items, Is.Not.Null);
    Assert.That(items.Count(), Is.EqualTo(3));

    CollectionAssert.AllItemsAreInstancesOfType(items, typeof(IModel));

    Assert.That(items.First(), Is.TypeOf<MyModel>());
    Assert.That(items.Skip(1).First(), Is.TypeOf<MyModel2>());
    Assert.That(items.Skip(2).First(), Is.TypeOf<MyModel>());
}

I've posted this code as a gist too: https://gist.github.com/leekelleher/0b1a9b9a0623e997031b8ac65f19d1c5

Nicholas-Westby commented 8 years ago

Do we think this is a good idea? Should we spend time to explore it?

Yep and yep :smile:

Here is one use case, assuming you are mapping content nodes:

I have a collection of IArticle. I want to use Ditto to populate that collection using instances of the classes Article, BlogPost, MarkdownPost, RichTextPost, PressRelease, and NewsPost. The Umbraco document type alias can be used to disambiguate between the items to know which class to instantiate.

Here is another use case, assuming you are mapping Archetype fieldsets:

I have a collection of IWidget. I want to use Ditto to populate the collection using instances of RichText, Image, Slideshow, ArticleGallery, PageSection, Grid. The Archetype fieldset alias can be used to disambiguate between the items to know which class to instantiate.

The common theme here is that you are using some aspect of the data to disambiguate between the classes to figure out which ones to instantiate. One way to accomplish this is with the [DittoDocType] attribute in your sample.

A more generic approach (perhaps too generic) would be to use a [DittoDisambiguator] attribute that could contain arbitrary logic to figure out which class to instantiate. It could contain as input the current bit of data (e.g., an IPublishedContent or ArchetypeModel) and a parameter (e.g., "MyModel2"). The output could be an instance of Type. For instance:

[DittoDisambiguator(typeof(DittoDocTypeDisambiguator), "MyModel2")]
public class MyModel2 : IModel { }

An Archetype example:

[DittoDisambiguator(typeof(DittoArchetypeAliasDisambiguator), "MyWidget2")]
public class MyWidet2 : IWidget { }

Obviously, the particulars of how exactly that attribute would be structured would be up for debate (e.g., generic type parameter vs using typeof to pass in an instance of Type).

micklaw commented 8 years ago

Added a quick PoC on this there. Based on the principles we spoke about with attributes and interfaces, I added a .CollectionAs() method, though as I say is purely for PoC.

I am sure with agreement we could work this in to the standard .As() method, this was purely to prove it should be quite easy to fire up.

https://github.com/micklaw/umbraco-ditto/blob/develop/tests/Our.Umbraco.Ditto.Tests/PolymorphicCollectionTests.cs

JimBobSquarePants commented 8 years ago

@leekelleher what would an empty [DittoDocType] do?

I have a collection of IArticle. I want to use Ditto to populate that collection using instances of the classes Article, BlogPost, MarkdownPost, RichTextPost, PressRelease, and NewsPost. The Umbraco document type alias can be used to disambiguate between the items to know which class to instantiate.

If these are nodes why not have them inherit a base class with shared properties like Id and DocumentTypeAlias?

That way you can use an IEnumerable<IPublishedContent> and invoke the type later on in the process. Here's an approach that would work with nested content for example.

if (Model.Content.NestedContent.Any())
{
    foreach (IPublishedContent item in Model.Content.NestedContent)
    {
        var alias = item.DocumentTypeAlias;
        var type = Type.GetType("Assembly, " + item.Alias);
        var viewModel = item.As(type);

        Html.RenderPartial("~/Views/Partials/NestedContent/_" + type.Name + ".cshtml", viewModel);
    }
}
Nicholas-Westby commented 8 years ago

why not have them inherit a base class with shared properties

BlogPost would have an author while PressRelease may not. Wouldn't want to have a base Post class with everything including the kitchen sink on it. If you are talking about the IPublishedContent interface (e.g., the Id and DocumentTypeAlias properties on that interface), I again don't need most things in that to render, say, a blog post. My blog post page does not care about page ID's or document type aliases.

That way you can use an IEnumerable and invoke the type later on in the process.

Then you are basically avoiding mapping entirely. Or at least deferring parts of it for no reason. If I have multiple partials that make use of that IEnumerable<IPublishedContent>, I then have to map those items in each of those partials (a performance problem). Had I mapped it to begin with in a controller, I could then pass the true view model around rather than an incomplete version of it.

Here is how I'd like your example to work:

// This mapping would ideally happen in the controller.
var mapped = Model.Content.As<MyContent>();
foreach (var item in mapped.NestedContentItems)
{
    Html.RenderPartial(item.Partial, item);
}

I've never used Nested Content, so some of that may not quite make sense, but you get the idea.

Nicholas-Westby commented 8 years ago

By the way, the above sample VERY closely matches my actual code on the site I'm working with right now that uses Ditto (and Ditto Archetype Resolvers) to map/render widgets:

@using MyApp.App.Pages
@using MyApp.App.Utilities
@inherits Umbraco.Web.Mvc.UmbracoViewPage<TypicalPage>
@{
    Layout = "Wrapped.cshtml";
    var page = Model;
    var widgets = page.Typical.MainContent;
}

@section PageTitle{@page.SEO.Title}

@foreach (var widget in widgets)
{
    var view = WidgetUtility.GetView(widget);
    if (view != null)
    {
        @Html.Partial(view, widget)
    }
}

@Html.Partial("Quad Actions", page)

@Html.Partial("Disclaimer", page)

You don't see any mention of Ditto in there because all the mapping was done in my default controller.

JimBobSquarePants commented 8 years ago

@Nicholas-Westby I didn't suggest the kitchen sink? What's in IArticle anyway? How do you manage strong typed document traversal without those properties?

I'm not deferring it for no reason, it's a fairly simple way of solving the problem with the current codebase.

That said I'm genuinely intrigued by the idea of supporting polymorphism and I'm really glad you're pushing this. I think I know of a way to get the types without having to pass the assembly name and building something like this.

Btw @leekelleher forget my question I asked, I've just realised. I'm gonna chat implementation since I think we're all keen to do it.

Umbraco makes this easy :smile: We use the following to get all the types that implement IArticle

var articles = PluginManager.Current.ResolveTypes<IArticle>();

That get's cached already which is lovely.

Knowing the type we can, inside our processor create an instance of the type in the collection that matches the doctype name and return the populated value.

Voila!

Nicholas-Westby commented 8 years ago

What's in IArticle anyway?

The properties common to each article (e.g., header, image, widgets). In the case of IWidget, it is actually just an empty interface to help Ditto Archetype Resolvers figure out which classes it needs to pay attention to (essentially, a reflection hint). If I had passed in "object", for example, Ditto Archetype Resolvers would have to scan every class for the attribute that disambiguates which class to instantiate. I'm sure that could be optimized though, so maybe passing in a base interface would not even be necessary (still nice to make what's in the collection more apparent).

How do you manage strong typed document traversal without those properties?

I don't quite follow this question. Could you expand upon it?

I'm gonna chat implementation since I think we're all keen to do it.

Sweet!

micklaw commented 8 years ago

Hey @JimBobSquarePants,

Umbraco also exposes a method on the PluginManager called ResolveAttributedTypes (again more than likely optimised, but we cache anyway), this is how we we implemented the Type to alias mapping in the Resolvers project. It enabled us loop all Types decorated with a DittoTypeAliasAttribute and we can then get the alias from that or from the actual class name. The PoC I mocked up earlier is doing exactly this with a unit test being added to prove the mapping to the interface works.

https://github.com/micklaw/umbraco-ditto/blob/develop/src/Our.Umbraco.Ditto/Common/TypeLocator.cs

mattbrailsford commented 8 years ago

What you are describing is a classic Factory pattern so I'd be tempted to go with something like:

public interface IModel { }

public class MyModel1 : IModel {}
public class MyModel2 : IModel {}

public class MyViewModel {
    [DittoFactory(typeof(DittoDocType))]
    public IEnumerable<IModel> MyProp { get; set; }
}

DittoFactoryAttribute then can be a generic way of handling polymorphism and can accept the type of a DittoFactory. We can define a base class for this with a method of "Build" or something that can accept a context so that it knows the target type, the interface, and information about the IPublishedContent. The factory's job then is just to construct the correct model based on the context.

mattbrailsford commented 8 years ago

In fact, I guess the only job of the specific instances of DittoFactory (ie, the DittoDocType) would be to resolve a Type, then the DittoFactory base class could handle instantiating it, and calling .As with it.

In fact, I'm pretty sure DittoFactoryAttribute could just be a processor.

mattbrailsford commented 8 years ago

Have created pull request #169 as a POC for how the DittoFactory would work as a processor

mattbrailsford commented 8 years ago

PS PROCESSORS ROCK! :metal:

leekelleher commented 8 years ago

@mattbrailsford As discussed via Skype, PR #169 looks great - I like the approach of leveraging processors here, makes most sense.

@micklaw Thank you for PoC code, it is appreciated! :bow:


I think we're all happy with the idea of being able to map polymorphic collections, so I think we can close this issue off and move the discussion over to PR #169

jamiepollock commented 8 years ago

Hey folks, Sorry if I missed the point, on the train. Surely for polymorphic widgets you'd use Html.DisplayFor() with the appropriate class named in your Views/Shared/DisplayTemplates/.cshtml. It's one of those features I love about Razor.

I've done some similar code for polymorphic widgets using an IWidget interface and DocTypeAlias + ViewModel class names. It works really well with Nested Content but should easily work with Archetype too. :)

Thanks, Jamie

On 4 May 2016, at 01:48, Nicholas-Westby notifications@github.com wrote:

By the way, the above sample VERY closely matches my actual code on the site I'm working with right now that uses Ditto (and Ditto Archetype Resolvers) to map/render widgets:

@using MyApp.App.Pages @using MyApp.App.Utilities @inherits Umbraco.Web.Mvc.UmbracoViewPage @{ Layout = "Wrapped.cshtml"; var page = Model; var widgets = page.Typical.MainContent; }

@section PageTitle{@page.SEO.Title}

@foreach (var widget in widgets) { var view = WidgetUtility.GetView(widget); if (view != null) { @Html.Partial(view, widget) } }

@Html.Partial("Quad Actions", page)

@Html.Partial("Disclaimer", page) You don't see any mention of Ditto in there because all the mapping was done in my default controller.

— You are receiving this because you are subscribed to this thread. Reply to this email directly or view it on GitHub

mattbrailsford commented 8 years ago

I do something similar using what was DitFlo, but I can see why someone might need it

Nicholas-Westby commented 8 years ago

@jamiepollock The rendering isn't what's really being talked about it. It's the mapping (for class instantiation, not for choosing the CSHTML) before the render process.

mattbrailsford commented 8 years ago

I think his point is you can map all to shared model for your loop, and within your specific views, you can map to specific models. With DittoView in 0.9 this is a really simple approach, and one I use. On 5 May 2016 8:03 pm, "Nicholas-Westby" notifications@github.com wrote:

@jamiepollock https://github.com/jamiepollock The rendering isn't what's really being talked about it. It's the mapping (for class instantiation, not for choosing the CSHTML) before the render process.

— You are receiving this because you were mentioned. Reply to this email directly or view it on GitHub https://github.com/leekelleher/umbraco-ditto/issues/168#issuecomment-217244919

Nicholas-Westby commented 8 years ago

For anybody who stumbles across this, I confirmed that this new processor works as expect. Thanks to everybody who got this working!

Tested with the latest AppVeyor build: https://ci.appveyor.com/project/leekelleher/umbraco-ditto/build/0.10.0.580

Here's my CSHTML file:

@using Our.Umbraco.Ditto
@using Wiski.App.Mapping.Models.Pages
@inherits Umbraco.Web.Mvc.UmbracoTemplatePage
@{
    Layout = "Primary.cshtml";
    var header = "Typical Page";
    var content = Model.Content.As<Typical>();
}

<h1>@header</h1>

@if (content == null)
{
    <div>Content is null.</div>
}
else
{
    <div>Content is something.</div>
    if (content.MainContent == null)
    {
        <div>Widgets are null.</div>
    }
    else
    {
        <div>Widgets are something.</div>
        foreach (var widget in content.MainContent)
        {
            <div>Got a widget: @(widget.GetType().Name)</div>
        }
    }
}

Here's my main model:

namespace Wiski.App.Mapping.Models.Pages
{

    // Namespaces.
    using Interfaces;
    using Our.Umbraco.Ditto;
    using Processors;
    using System.Collections.Generic;

    /// <summary>
    /// The typical page.
    /// </summary>
    public class Typical
    {
        [DittoArchetype]
        [DittoDocTypeFactory]
        public IEnumerable<IWidget> MainContent { get; set; }
    }

}

Here's the processor that allows for Archetype fieldsets to be mapped:

namespace Wiski.App.Mapping.Processors
{

    // Namespaces.
    using Archetype.Extensions;
    using Archetype.Models;
    using Our.Umbraco.Ditto;
    using Umbraco.Core.Models;

    /// <summary>
    /// Allows for mapping of Archetype properties.
    /// </summary>
    /// <remarks>
    /// Snagged from: https://github.com/leekelleher/umbraco-ditto-labs/blob/develop/src/Our.Umbraco.Ditto.Archetype/ComponentModel/Processors/DittoArchetypeAttribute.cs
    /// </remarks>
    public class DittoArchetypeAttribute : UmbracoPropertyAttribute
    {

        #region Constructors

        /// <summary>
        /// Default constructor.
        /// </summary>
        public DittoArchetypeAttribute()
            : base()
        { }

        /// <summary>
        /// Full constructor.
        /// </summary>
        /// <param name="propertyName">
        /// The name of the property.
        /// </param>
        /// <param name="altPropertyName">
        /// The alternate property name.
        /// </param>
        /// <param name="recursive">
        /// Map recursively?
        /// </param>
        /// <param name="defaultValue">
        /// The default value.
        /// </param>
        public DittoArchetypeAttribute(
            string propertyName,
            string altPropertyName = null,
            bool recursive = false,
            object defaultValue = null)
            : base(propertyName, altPropertyName, recursive, defaultValue)
        { }

        #endregion

        #region Methods

        /// <summary>
        /// Process the value.
        /// </summary>
        /// <returns>
        /// The processed value.
        /// </returns>
        public override object ProcessValue()
        {
            var value = this.Value;

            if (value is IPublishedContent)
            {
                value = base.ProcessValue();
            }

            if (value is ArchetypeModel)
            {
                return ((ArchetypeModel)value).ToPublishedContentSet();
            }

            if (value is ArchetypeFieldsetModel)
            {
                return ((ArchetypeFieldsetModel)value).ToPublishedContent();
            }

            return value;
        }

        #endregion

    }

}

Here's my widget interface:

namespace Wiski.App.Mapping.Interfaces
{

    /// <summary>
    /// All widgets implement this interface.
    /// </summary>
    public interface IWidget
    {
    }

}

Here's my rich text widget:

namespace Wiski.App.Mapping.Models.Widgets
{

    // Namespaces.
    using Interfaces;

    /// <summary>
    /// A rich text widget.
    /// </summary>
    public class RichText : IWidget
    {
        public string Text { get; set; }
    }

}

Here's my text callout widget:

namespace Wiski.App.Mapping.Models.Widgets
{

    // Namespaces.
    using Interfaces;

    /// <summary>
    /// A callout that contains a header and a summary.
    /// </summary>
    public class TextCallout : IWidget
    {
        public string Header { get; set; }
        public string Summary { get; set; }
    }

}

Here's what the back office looks like:

backoffice

And here's what the frontend of the website looks like:

frontend