PropertyLens.java
package sprouts.impl;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import sprouts.*;
import java.util.Objects;
import java.util.function.BiFunction;
import java.util.function.Function;
/**
* The Sprouts Property Lens is based on the Lens design pattern, which is a functional programming
* technique used to simplify the process of accessing and updating parts of
* a nested (immutable) data structures into a new instance of the data structure.
* It is essentially a pair of functions, one to get a value from a specific
* part of a data structure, and another to set or update that value while producing a new
* instance of the data structure. This pattern is particularly useful with Java records,
* which are immutable by design, as it allows for clean and concise transformative updates
* of deeply nested fields without breaking immutability.
* <p>
* <b>Now what does this have to do with Sprouts properties?</b>
* After all, the MVVM properties of this library are mutable
* wrapper types with regular getter and setter methods.
* Although properties are mutable, their items are expected to
* be immutable data carriers, such as ints, doubles, strings or records.
* In case of records (or other custom value oriented data types),
* there is really no limit to how deeply nested the data structure can be.
* You may even want to model your entire application state as a single record
* composed of other records, lists, maps and primitives.
* <p>
* <b>This is where the Property Lens comes in:</b><br>
* You can create a lens property from any regular property
* holding an immutable data structure, and then use the lens property
* like a regular mutable {@link Var} property. <br>
* This lets you interact with an immutable field as if it were mutable.
* Under the hood the lens property will use the lens pattern to access
* and update the nested data structure of the original property automatically.
* <p>
* The source-specific behavior (single parent vs. dual parents) is encapsulated
* in a {@link LensCore} implementation, making this class a unified wrapper
* for all lens property variants.
* <p>
* <b>Note that this class is not limited to lenses.</b> Everything a lens property needs
* beyond the lens pattern itself — deriving an item from an arbitrary number of source
* properties, recomputing it whenever any of them changes, degrading to the last known item
* when that recomputation fails, and observing all of the sources through weak listeners
* — is exactly what a composite view needs as well. A {@link CompositeCore} therefore
* turns this class into the read-only composite view behind
* {@link sprouts.Viewable#of(Object, Function)}, in which case it reports itself as a view
* instead of a lens (see {@link LensCore#isView()}).
*
* @param <T> The type of the value, which is expected to be an immutable data carrier,
* such as a record, value object, or a primitive.
*/
final class PropertyLens<T extends @Nullable Object> implements Var<T>, Viewable<T>
{
private static final Logger log = org.slf4j.LoggerFactory.getLogger(PropertyLens.class);
// ==================== Single-source factory methods ====================
static <A, B> Var<B> of(Var<A> source, @Nullable Class<B> type, Lens<A, B> lens) {
Objects.requireNonNull(source);
Objects.requireNonNull(lens);
/*
A plain lens is null-safe: it promises 'allowsNull() == false'. It cannot keep
that promise over a nullable parent, because a null parent has no field to focus
on. So instead of silently letting null leak into a supposedly non-null property,
we reject the nullable parent right here, where the lens is being derived, and
point the user at the two null-aware alternatives.
*/
if ( source.allowsNull() )
throw new IllegalArgumentException(
"Cannot create a null-safe lens from a nullable parent property. " +
"Use 'zoomToNullable(..)' for a lens that may itself be null, or " +
"'zoomTo(nullObject, ..)' to substitute a value while the parent is null."
);
B initialValue;
try {
initialValue = lens.getter(Util.fakeNonNull(source.orElseNull()));
} catch (Exception e) {
Util.sneakyThrowExceptionIfFatal(e);
throw new IllegalArgumentException("Lens getter must not throw an exception", e);
}
if ( type == null ) {
if ( initialValue == null ) {
throw new NullPointerException(
"Unable to infer lens property type from a null initial value. " +
"Please provide an explicit type or use the overload with a null object."
);
}
type = Util.expectedClassFromItem(initialValue);
}
LensCore<B> core = new SingleLensCore<>(source, lens);
return new PropertyLens<>(type, Sprouts.factory().defaultId(), false, initialValue, core, null);
}
static <A, B, V extends B> Var<B> of(Var<A> source, @Nullable Class<B> type, V nullObject, Lens<A, B> lens) {
Objects.requireNonNull(source, "Source must not be null");
Objects.requireNonNull(nullObject, "Null object must not be null");
Objects.requireNonNull(lens, "Lens must not be null");
if ( type == null )
type = Util.expectedClassFromItem(nullObject);
B initialValue;
try {
initialValue = lens.getter(Util.fakeNonNull(source.orElseNull()));
} catch ( Exception e ) {
Util.sneakyThrowExceptionIfFatal(e);
throw new IllegalArgumentException(
"Failed to fetch initial value from source property " +
"using the provided lens getter.",
e
);
}
LensCore<B> core = new SingleLensCore<>(source, lens);
return new PropertyLens<>(type, Sprouts.factory().defaultId(), false, initialValue, core, null);
}
static <A, B> Var<B> ofNullable(Class<B> type, Var<A> source, Lens<A, B> lens) {
Objects.requireNonNull(type, "Type must not be null");
Objects.requireNonNull(lens, "Lens must not be null");
B initialValue;
try {
initialValue = lens.getter(Util.fakeNonNull(source.orElseNull()));
} catch ( Exception e ) {
Util.sneakyThrowExceptionIfFatal(e);
throw new IllegalArgumentException(
"Failed to fetch initial value from source property " +
"using the provided lens getter.",
e
);
}
LensCore<B> core = new SingleLensCore<>(source, lens);
return new PropertyLens<>(type, Sprouts.factory().defaultId(), true, initialValue, core, null);
}
static <A, B> Var<B> ofProjection(Var<A> source, @Nullable Class<B> type, Function<A,B> getter, Function<B,A> setter) {
// A plain projection is null-safe, exactly like a plain lens, and so it likewise
// refuses to be derived from a nullable source. Use 'projectToNullable(..)' or a
// null object instead.
if ( source.allowsNull() )
throw new IllegalArgumentException(
"Cannot create a null-safe projection from a nullable source property. " +
"Use 'projectToNullable(..)' for a projection that may itself be null, or " +
"'projectTo(nullObject, ..)' to substitute a value while the source is null."
);
Lens<A,B> lens = Lens.of(getter, (a,b)->setter.apply(b));
B initialValue;
try {
initialValue = lens.getter(Util.fakeNonNull(source.orElseNull()));
} catch (Exception e) {
Util.sneakyThrowExceptionIfFatal(e);
throw new IllegalArgumentException("Lens getter must not throw an exception", e);
}
if ( type == null ) {
if ( initialValue == null ) {
throw new NullPointerException(
"Unable to infer lens property type from a null initial value. " +
"Please provide an explicit type or use the overload with a null object."
);
}
type = Util.expectedClassFromItem(initialValue);
}
LensCore<B> core = new SingleLensCore<>(source, lens);
return new PropertyLens<>(type, Sprouts.factory().defaultId(), false, initialValue, core, null);
}
// ==================== Dual-source factory methods ====================
/**
* Creates a non-null dual projection lens with the type inferred from the initial computed value.
*/
static <A, B, C> Var<C> ofDualProjection(
@Nullable Class<C> type,
Var<A> first,
Var<B> second,
BiFunction<A, B, C> getter,
Function<C, Pair<A, B>> setter
) {
C initialValue;
try {
initialValue = getter.apply(Util.fakeNonNull(first.orElseNull()), Util.fakeNonNull(second.orElseNull()));
} catch ( Exception e ) {
Util.sneakyThrowExceptionIfFatal(e);
throw new IllegalArgumentException("Getter function must not throw an exception on initial call", e);
}
if ( initialValue == null )
throw new NullPointerException(
"The getter function returned null on the initial call, " +
"but the property does not allow null values!"
);
if ( type == null )
type = Util.expectedClassFromItem(initialValue);
LensCore<C> core = new DualLensCore<>(first, second, getter, setter);
return new PropertyLens<>(type, Sprouts.factory().defaultId(), false, initialValue, core, null);
}
/**
* Creates a non-null dual projection lens with a null-fallback value.
* When either source property's item is {@code null}, the {@code nullObject} is returned.
*/
static <A, B, C, V extends C> Var<C> ofDualProjectionWithFallback(
@Nullable Class<C> type,
V nullObject,
Var<A> first,
Var<B> second,
BiFunction<A, B, @Nullable C> getter,
Function<C, Pair<A, B>> setter
) {
if ( type == null )
type = Util.expectedClassFromItem(nullObject);
final C fallback = nullObject;
BiFunction<@Nullable A, @Nullable B, C> safeGetter = (a, b) -> {
if ( a == null || b == null ) return fallback;
C result;
try {
result = getter.apply(a, b);
} catch ( Exception e ) {
Util.sneakyThrowExceptionIfFatal(e);
_logError(
"Dual lens failed to fetch value from source properties " +
"using the provided lens getter.", e
);
return fallback;
}
return result != null ? result : fallback;
};
C initialValue = safeGetter.apply(Util.fakeNonNull(first.orElseNull()), Util.fakeNonNull(second.orElseNull()));
LensCore<C> core = new DualLensCore<>(first, second, safeGetter, setter);
return new PropertyLens<>(type, Sprouts.factory().defaultId(), false, initialValue, core, null);
}
/**
* Creates a nullable dual projection lens.
*/
static <A, B, C> Var<C> ofDualProjectionNullable(
Class<C> type,
Var<A> first,
Var<B> second,
BiFunction<A, B, @Nullable C> getter,
Function<C, Pair<A, B>> setter
) {
C initialValue;
try {
initialValue = getter.apply(Util.fakeNonNull(first.orElseNull()), Util.fakeNonNull(second.orElseNull()));
} catch ( Exception e ) {
Util.sneakyThrowExceptionIfFatal(e);
initialValue = null;
}
LensCore<C> core = new DualLensCore<>(first, second, getter, setter);
return new PropertyLens<>(type, Sprouts.factory().defaultId(), true, initialValue, core, null);
}
// ==================== Parameterized projection factory methods ====================
/**
* Creates a non-null parameterized projection lens. The projection depends on a
* read-only {@link Val} parameter; writes never modify the parameter, only the source.
*/
static <P, A, B> Var<B> ofParamProjection(
@Nullable Class<B> type,
Val<P> parameter,
Var<A> source,
BiFunction<P, A, B> getter,
BiFunction<B, P, A> setter
) {
// A plain parameterized projection is null-safe, just like a plain lens or
// projection, and so it likewise refuses a nullable source. (The read-only
// parameter may still be nullable — it is passed through to the getter, which
// opts into handling it, exactly as the fallback variant already does.)
if ( source.allowsNull() )
throw new IllegalArgumentException(
"Cannot create a null-safe parameterized projection from a nullable source property. " +
"Use 'projectToNullable(..)' for a projection that may itself be null, or " +
"'projectTo(nullObject, parameter, ..)' to substitute a value while the source is null."
);
B initialValue;
try {
initialValue = getter.apply(Util.fakeNonNull(parameter.orElseNull()), Util.fakeNonNull(source.orElseNull()));
} catch ( Exception e ) {
Util.sneakyThrowExceptionIfFatal(e);
throw new IllegalArgumentException("Getter function must not throw an exception on initial call", e);
}
if ( initialValue == null )
throw new NullPointerException(
"The getter function returned null on the initial call, " +
"but the property does not allow null values!"
);
if ( type == null )
type = Util.expectedClassFromItem(initialValue);
LensCore<B> core = new ParamLensCore<>(parameter, source, getter, setter);
return new PropertyLens<>(type, Sprouts.factory().defaultId(), false, initialValue, core, null);
}
/**
* Creates a non-null parameterized projection lens with a null-fallback value.
* When the source property's item is {@code null}, or when the getter would otherwise
* return {@code null}, the provided {@code nullObject} is used instead.
*/
static <P, A, B, V extends B> Var<B> ofParamProjectionWithFallback(
@Nullable Class<B> type,
V nullObject,
Val<P> parameter,
Var<A> source,
BiFunction<P, A, @Nullable B> getter,
BiFunction<B, P, A> setter
) {
if ( type == null )
type = Util.expectedClassFromItem(nullObject);
final B fallback = nullObject;
BiFunction<@Nullable P, @Nullable A, B> safeGetter = (p, a) -> {
if ( a == null ) return fallback;
B result;
try {
result = getter.apply(p, a);
} catch ( Exception e ) {
Util.sneakyThrowExceptionIfFatal(e);
_logError(
"Parameterized lens failed to fetch value from source property " +
"using the provided getter.", e
);
return fallback;
}
return result != null ? result : fallback;
};
B initialValue = safeGetter.apply(Util.fakeNonNull(parameter.orElseNull()), Util.fakeNonNull(source.orElseNull()));
LensCore<B> core = new ParamLensCore<>(parameter, source, safeGetter, setter);
return new PropertyLens<>(type, Sprouts.factory().defaultId(), false, initialValue, core, null);
}
/**
* Creates a nullable parameterized projection lens.
*/
static <P, A, B> Var<B> ofParamProjectionNullable(
Class<B> type,
Val<P> parameter,
Var<A> source,
BiFunction<P, A, @Nullable B> getter,
BiFunction<B, P, A> setter
) {
B initialValue;
try {
initialValue = getter.apply(Util.fakeNonNull(parameter.orElseNull()), Util.fakeNonNull(source.orElseNull()));
} catch ( Exception e ) {
Util.sneakyThrowExceptionIfFatal(e);
initialValue = null;
}
LensCore<B> core = new ParamLensCore<>(parameter, source, getter, setter);
return new PropertyLens<>(type, Sprouts.factory().defaultId(), true, initialValue, core, null);
}
// ==================== Composite factory method ====================
/**
* Creates a read-only composite view which folds the items of an arbitrary number of
* joined properties into a single item, starting at the supplied {@code seed}.
* <p>
* Deriving a composite view fails fast: if the initial fold cannot produce an item,
* because a combiner returned {@code null} or threw an exception, then this method throws
* a {@link NullPointerException} instead of handing out a view which cannot honour its
* promise of never holding {@code null}. Once the view is live, the very same failures
* merely make it retain its last item (see {@link CompositeCore#fetchFromSources(Object, boolean)}).
* <p>
* If no property is joined at all, or if every joined property is immutable, then the
* composite item can never change, and so an immutable property is returned instead of a
* live view.
*/
static <C> Viewable<C> ofComposite( Class<C> type, C seed, Tuple<CompositeCore.Join<C, ?>> joins ) {
Objects.requireNonNull(type);
Objects.requireNonNull(seed);
CompositeCore<C> core = new CompositeCore<>(type, seed, joins);
/*
The initial fold is the one place where we may not degrade to a last known item,
simply because there is none yet. So we let the core log whatever went wrong and
then reject the null it had to fall back to.
*/
@Nullable C initialItem = core.fetchFromSources(null, true);
if ( initialItem == null )
throw new NullPointerException(
"Failed to compute the initial item of a composite view, because one of its " +
"combiners returned null or threw an exception. A composite view does not allow " +
"null items, so it cannot be created from a fold which does not produce an item."
);
if ( !core.sources().iterator().hasNext() )
return Viewable.cast(Property.of(true, type, initialItem)); // Nothing can ever change it.
return new PropertyLens<>(type, Sprouts.factory().defaultId(), false, initialItem, core, null);
}
// ==================== Instance fields ====================
private final PropertyChangeListeners<T> _changeListeners;
private final String _id;
private final boolean _nullable;
private final Class<T> _type;
private final LensCore<T> _core;
private @Nullable T _lastItem;
// ==================== Constructor ====================
private PropertyLens(
Class<T> type,
String id,
boolean allowsNull,
@Nullable T initialItem,
LensCore<T> core,
@Nullable PropertyChangeListeners<T> changeListeners
) {
Objects.requireNonNull(id);
Objects.requireNonNull(type);
Objects.requireNonNull(core);
_type = type;
_id = id;
_nullable = allowsNull;
_core = core;
_changeListeners = changeListeners == null ? new PropertyChangeListeners<>() : new PropertyChangeListeners<>(changeListeners);
_lastItem = initialItem;
for ( Val<?> source : _core.sources() ) {
Viewable.cast(source).onChange(From.ALL, WeakAction.of(this, (thisLens, v) -> {
if ( thisLens._core.shouldSuppressSourceCallback() ) return;
T newValue = thisLens._fetchFromSources(true);
ItemPair<T> pair = new ItemPair<>(thisLens._type, newValue, thisLens._lastItem);
if ( pair.change() != SingleChange.NONE || v.change() == SingleChange.NONE ) {
thisLens._lastItem = newValue;
thisLens.fireChange(v.channel(), pair);
}
}));
}
if ( !Sprouts.factory().isValidPropertyId(_id) )
throw new IllegalArgumentException("The provided id '"+_id+"' is not valid! It must match the pattern '"+Sprouts.factory().idPattern().pattern()+"'");
if ( !allowsNull && initialItem == null )
throw new IllegalArgumentException("The provided initial value is null, but the property does not allow null values!");
}
// ==================== Var contract ====================
/**
* Fetches the current item from the lens sources, applying graceful degradation:
* if this lens does not allow null, but the sources currently yield {@code null}
* (e.g. because the focused field became null through an update of the parent),
* then we keep the last known item instead of exposing an illegal null item.
* This mirrors how {@link SingleLensCore#fetchFromSources(Object, boolean)} already keeps
* the last item when the lens getter throws, and it guarantees that a
* non-nullable lens never violates its own {@code allowsNull() == false} contract.
* <p>
* The {@code logDegradation} flag exists so that only the event-propagation path
* (where the anomaly first occurs) reports it, while ordinary reads
* ({@code get()} / {@code orElseNull()}) stay silent — otherwise a single null
* focused field would spam an error log on every read for as long as it stays null.
*/
private @Nullable T _fetchFromSources(boolean logDegradation) {
@Nullable T fetched = _core.fetchFromSources(_lastItem, logDegradation);
if ( fetched == null && !_nullable ) {
if ( logDegradation )
_logError(
"The lens property '{}' does not allow null items, but its focused source " +
"field is currently null. Keeping the last known item '{}' instead. " +
"Use a 'zoomTo(nullObject, ...)' or 'zoomToNullable(..)' lens to model a " +
"missing field explicitly.",
_id, _lastItem
);
return _lastItem;
}
return fetched;
}
private @Nullable T _item() {
@Nullable T currentItem = _fetchFromSources(false);
if ( currentItem != null ) {
Class<?> currentType = currentItem.getClass();
if ( !_type.isAssignableFrom(currentType) )
throw new IllegalArgumentException(String.format(
"The provided type '%s' of the initial value is not compatible " +
"with the actual type '%s' of the variable", currentType, _type
));
}
return currentItem;
}
/** {@inheritDoc} */
@Override public final Class<T> type() { return _type; }
/** {@inheritDoc} */
@Override public final String id() { return _id; }
/** {@inheritDoc} */
@Override
public final @Nullable T orElseNull() { return _item(); }
/** {@inheritDoc} */
@Override public final boolean allowsNull() { return _nullable; }
@Override
public boolean isMutable() {
return true;
}
@Override
public boolean isLens() {
return !_core.isView();
}
@Override
public boolean isView() {
return _core.isView();
}
@Override
public final String toString() {
String value = "?";
try {
value = this.mapTo(String.class, Object::toString).orElse("null");
} catch ( Exception e ) {
Util.sneakyThrowExceptionIfFatal(e);
value = e.toString();
_logError(
"Failed to convert the item of type '{}' to a string for property lens with id '{}'.",
_type, _id, e
);
}
String id = this.id() == null ? "?" : this.id();
if ( id.equals(Sprouts.factory().defaultId()) ) id = "?";
String type = ( type() == null ? "?" : type().getSimpleName() );
if ( type.equals("Object") ) type = "?";
if ( type.equals("String") && this.isPresent() ) value = "\"" + value + "\"";
if (_nullable) type = type + "?";
String name = _core.coreName();
String content = ( id.equals("?") ? value : id + "=" + value );
return name + "<" + type + ">" + "[" + content + "]";
}
/** {@inheritDoc} */
@Override public final Var<T> withId( String id ) {
return new PropertyLens<>(_type, id, _nullable, _item(), _core.newInstance(), _changeListeners);
}
@Override
public Viewable<T> onChange( Channel channel, Action<ValDelegate<T>> action ) {
_changeListeners.onChange(channel, action);
return this;
}
/** {@inheritDoc} */
@Override public final Var<T> fireChange( Channel channel ) {
this.fireChange(channel, new ItemPair<>(this));
return this;
}
void fireChange( Channel channel, ItemPair<T> pair ) {
_changeListeners.fireChange(this, channel, pair);
}
/** {@inheritDoc} */
@Override
public final Var<T> set( Channel channel, T newItem ) {
Objects.requireNonNull(channel);
/*
Rejected up front, and not only down in the core: _setInternal() records the new item
as the fallback for failed recomputations before it writes to the sources, so letting
a read-only core fail down there would leave an item behind which was never really set.
*/
if ( _core.isView() )
throw new UnsupportedOperationException(
"The '" + _core.coreName() + "' property is read-only! " +
"Change one of the properties it is derived from instead."
);
ItemPair<T> pair = _setInternal(channel, newItem);
if ( pair.change() != SingleChange.NONE )
this.fireChange(channel, pair);
return this;
}
private ItemPair<T> _setInternal( Channel channel, T newValue ) {
if ( !_nullable && newValue == null )
throw new NullPointerException(
"This property is configured to not allow null values! " +
"If you want your property to allow null values, use the 'ofNullable(Class, T)' factory method."
);
T oldValue = _item();
ItemPair<T> pair = new ItemPair<>(_type, newValue, oldValue);
if ( pair.change() != SingleChange.NONE ) {
if ( newValue != null && !_type.isAssignableFrom(newValue.getClass()) )
throw new IllegalArgumentException(String.format(
"The provided type '%s' of the new value is not compatible " +
"with the expected item type '%s' of this property lens.", newValue.getClass(), _type
));
_lastItem = newValue;
_core.writeToSources(channel, newValue);
}
return pair;
}
@Override
public final Observable subscribe(Observer observer ) {
_changeListeners.onChange( observer );
return this;
}
@Override
public final Observable unsubscribe(Subscriber subscriber ) {
_changeListeners.unsubscribe(subscriber);
return this;
}
@Override
public void unsubscribeAll() {
_changeListeners.unsubscribeAll();
}
public final long numberOfChangeListeners() {
return _changeListeners.numberOfChangeListeners();
}
private static void _logError(String message, @Nullable Object... args) {
Util._logError(log, message, args);
}
}