CompositeCore.java

package sprouts.impl;

import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import sprouts.Channel;
import sprouts.Val;
import sprouts.Viewable;

import sprouts.Tuple;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.BiFunction;

/**
 *  A read-only {@link LensCore} which folds the items of an arbitrary number of joined
 *  properties into a single composite item, starting at a seed item.
 *  It is the engine behind {@link Viewable#of(Object, java.util.function.Function)} and
 *  {@link Viewable#of(Class, Object, java.util.function.Function)}.
 *  <p>
 *  Contrary to the lens cores, this one derives its item from <b>all</b> of its sources at once,
 *  which is what makes a composite view free of intermediate items: every recomputation reads
 *  the current item of every joined property instead of remembering what it was told, so it
 *  cannot mix a fresh item of one source with a stale item of another.
 *  <p>
 *  The fold is applied atomically: should any of the combiners fail, produce {@code null} or
 *  produce an item which does not fit the item type of the composite, then the <b>whole</b>
 *  recomputation is discarded in favour of the last known item, so that a composite view is
 *  never observed in a half updated state.
 *
 * @param <C> The item type of the composite property.
 */
final class CompositeCore<C> implements LensCore<C> {

    private static final Logger log = org.slf4j.LoggerFactory.getLogger(CompositeCore.class);

    /**
     *  A single contribution to the fold of a composite view, which is one joined
     *  property together with the combiner folding its item into the composite item.
     *  <p>
     *  Note that the joined property is held through a {@link ParentRef}, which is how
     *  a composite view follows the same referencing policy as any other view: a joined
     *  view or lens is referenced strongly, so that intermediate properties created inline
     *  inside a configurator stay alive, whereas a plain property is referenced weakly, so
     *  that observing your state never keeps it alive.
     */
    static final class Join<C, V extends @Nullable Object> {
        private final ParentRef<Val<V>>    _property;
        private final BiFunction<C, V, C>  _combiner;

        Join( Val<V> property, BiFunction<C, V, C> combiner ) {
            _property = ParentRef.of(property); // Vetted by CompositeBuilderImpl, the only place creating these.
            _combiner = combiner;
        }

        Val<V> property() { return _property.get(); }

        C applyTo( C item ) {
            return _combiner.apply(item, _property.get().orElseNull());
        }
    }

    private final Class<C>          _type;
    private final C                 _seed;
    private final Tuple<Join<C, ?>> _joins;

    CompositeCore( Class<C> type, C seed, Tuple<Join<C, ?>> joins ) {
        _type  = Objects.requireNonNull(type);
        _seed  = Objects.requireNonNull(seed);
        _joins = Objects.requireNonNull(joins); // A tuple is immutable, so there is nothing to defend against here.
    }

    /**
     *  Folds the items of all joined properties into a single composite item,
     *  starting at the seed and applying every combiner in join order.
     *
     * @param lastKnownItem The item to fall back to if the fold fails, which may be
     *                      {@code null} while the composite view is still being derived.
     * @param logDegradation Whether falling back to {@code lastKnownItem} should be logged.
     * @return The folded item, or {@code lastKnownItem} if the fold failed.
     */
    @Override
    public @Nullable C fetchFromSources( @Nullable C lastKnownItem, boolean logDegradation ) {
        C folded = _seed;
        for ( int i = 0; i < _joins.size(); i++ ) {
            Join<C, ?> join = _joins.get(i);
            try {
                folded = join.applyTo(folded);
            } catch ( Exception e ) {
                Util.sneakyThrowExceptionIfFatal(e);
                if ( logDegradation )
                    _logError(
                        "The combiner joined to property {} (at index {}) of a composite view " +
                        "threw an exception. The whole recomputation is discarded and the " +
                        "composite view retains its last item '{}'.",
                        _describe(join.property()), i, lastKnownItem, e
                    );
                return lastKnownItem;
            }
            if ( folded == null ) {
                if ( logDegradation )
                    _logError(
                        "The combiner joined to property {} (at index {}) of a composite view " +
                        "returned null, but a composite view does not allow null items. " +
                        "The whole recomputation is discarded and the composite view retains " +
                        "its last item '{}'.",
                        _describe(join.property()), i, lastKnownItem
                    );
                return lastKnownItem;
            }
        }
        if ( !_type.isInstance(folded) ) {
            if ( logDegradation )
                _logError(
                    "The combiners of a composite view produced an item of type '{}', which does " +
                    "not fit the item type '{}' of the view. The whole recomputation is discarded " +
                    "and the composite view retains its last item '{}'. Use the " +
                    "'Viewable.of(Class, Object, Function)' factory method to declare a common " +
                    "supertype explicitly.",
                    folded.getClass(), _type, lastKnownItem
                );
            return lastKnownItem;
        }
        return folded;
    }

    @Override
    public void writeToSources( Channel channel, @Nullable C newItem ) {
        throw new UnsupportedOperationException(
            "A composite view is read-only! Change one of the properties it is composed of instead."
        );
    }

    /**
     *  {@inheritDoc}
     *  <p>
     *  The returned list is computed on demand and deliberately <b>not</b> retained in a field:
     *  holding it would be a strong reference to every joined property, which would defeat the
     *  weak {@link ParentRef}s the joins are based on. The {@link PropertyLens} only consumes
     *  this once, when it registers its weak listeners.
     *  <p>
     *  Immutable properties are left out, because their item can never change, so observing
     *  them would only produce listeners which are never called. They are still folded into
     *  the composite item, they are just not listened to.
     *  <p>
     *  A property which was joined more than once is reported only once, because the fold reads
     *  the current item of every join anyway, so a second listener on the same property would
     *  only recompute the very same item again. Worse, a forced change event, which is
     *  propagated even when the item did not change, would then be multiplied by the number
     *  of times the property was joined.
     */
    @Override
    public List<? extends Val<?>> sources() {
        List<Val<?>> observed = new ArrayList<>(_joins.size());
        for ( Join<C, ?> join : _joins ) {
            Val<?> property = join.property();
            if ( property.isMutable() && !_containsIdentical(observed, property) )
                observed.add(property);
        }
        return observed;
    }

    private static boolean _containsIdentical( List<Val<?>> properties, Val<?> property ) {
        for ( int i = 0; i < properties.size(); i++ )
            if ( properties.get(i) == property )
                return true;
        return false;
    }

    @Override
    public boolean isView() {
        return true;
    }

    @Override
    public boolean shouldSuppressSourceCallback() {
        return false;
    }

    @Override
    public String coreName() {
        return "View";
    }

    @Override
    public LensCore<C> newInstance() {
        return this; // no mutable state — safe to share
    }

    private static String _describe( Val<?> property ) {
        return property.id().isEmpty() ? "of type '" + property.type() + "'" : "'" + property.id() + "'";
    }

    private static void _logError( String message, @Nullable Object... args ) {
        Util._logError(log, message, args);
    }
}