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 (like a record),
* 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 manipulation
* of deeply nested fields without breaking immutability.
* <p>
* Now what does this have to do with Sprouts properties?
* 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 property. <br>
* Under the hood the lens property will use the lens pattern to access
* and update the nested data structure of the original property.
*
* @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<A extends @Nullable Object, T extends @Nullable Object> implements Var<T>, Viewable<T>
{
private static final Logger log = org.slf4j.LoggerFactory.getLogger(PropertyLens.class);
public static <T, B> Var<B> of(Var<T> source, B nullObject, Function<T, B> getter, BiFunction<T, B, T> wither) {
Objects.requireNonNull(nullObject, "Null object must not be null");
Objects.requireNonNull(getter, "Getter must not be null");
Objects.requireNonNull(wither, "Wither must not be null");
Class<B> itemType = Util.expectedClassFromItem(nullObject);
Function<T,B> nullSafeGetter = newParentValue -> {
if ( newParentValue == null )
return nullObject;
return getter.apply(newParentValue);
};
BiFunction<T,B,T> nullSafeWither = (parentValue, newValue) -> {
if ( parentValue == null )
return null;
return wither.apply(parentValue, newValue);
};
B initialValue = nullSafeGetter.apply(source.orElseNull());
return new PropertyLens<>(
itemType,
Sprouts.factory().defaultId(),
false,//does not allow null
initialValue, //may NOT be null
source,
nullSafeGetter,
nullSafeWither,
null
);
}
public static <T, B> Var<B> ofNullable(Class<B> type, Var<T> source, Function<T, B> getter, BiFunction<T, B, T> wither) {
Objects.requireNonNull(type, "Type must not be null");
Objects.requireNonNull(getter, "Getter must not be null");
Objects.requireNonNull(wither, "Wither must not be null");
Function<T,B> nullSafeGetter = newParentValue -> {
if ( newParentValue == null )
return null;
return getter.apply(newParentValue);
};
BiFunction<T,B,T> nullSafeWither = (parentValue, newValue) -> {
if ( parentValue == null )
return null;
return wither.apply(parentValue, newValue);
};
B initialValue = nullSafeGetter.apply(source.orElseNull());
return new PropertyLens<>(
type,
Sprouts.factory().defaultId(),
true,//allows null
initialValue, //may be null
source,
nullSafeGetter,
nullSafeWither,
null
);
}
private final ChangeListeners<T> _changeListeners;
private final String _id;
private final boolean _nullable;
private final Class<T> _type;
private final Var<A> _parent;
Function<A,@Nullable T> _getter;
BiFunction<A,@Nullable T,A> _setter;
private @Nullable T _lastItem;
public PropertyLens(
Class<T> type,
String id,
boolean allowsNull,
@Nullable T initialItem, // may be null
Var<A> parent,
Function<A,@Nullable T> getter,
BiFunction<A,@Nullable T,A> wither,
@Nullable ChangeListeners<T> changeListeners
) {
Objects.requireNonNull(id);
Objects.requireNonNull(type);
_type = type;
_id = id;
_nullable = allowsNull;
_parent = parent;
_getter = getter;
_setter = wither;
_changeListeners = changeListeners == null ? new ChangeListeners<>() : new ChangeListeners<>(changeListeners);
_lastItem = initialItem;
Viewable.cast(parent).onChange(From.ALL, Action.ofWeak(this, (thisLens, v) -> {
T newValue = thisLens._fetchItemFromParent();
if (!Objects.equals(thisLens._lastItem, newValue)) {
thisLens._lastItem = newValue;
thisLens.fireChange(v.channel());
}
}));
if ( !Sprouts.factory().idPattern().matcher(_id).matches() )
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!");
}
private String _idForError(String id) {
return id.isEmpty() ? "" : "'"+id+"' ";
}
private @Nullable T _fetchItemFromParent() {
T fetchedValue = _lastItem;
try {
fetchedValue = _getter.apply(_parent.orElseNull());
} catch ( Exception e ) {
log.error(
"Failed to fetch item of type '"+_type+"' for property lens "+ _idForError(_id) +
"from parent property "+ _idForError(_parent.id())+"(with item type '"+_parent.type()+"') " +
"using the current getter lambda.",
e
);
}
return fetchedValue;
}
private void _setInParentAndInternally(Channel channel, @Nullable T newItem) {
try {
A newParentItem = _setter.apply(_parent.orElseNull(), newItem);
_lastItem = newItem;
_parent.set(channel, newParentItem);
} catch ( Exception e ) {
log.error(
"Property lens "+_idForError(_id)+"(for item type '"+_type+"') failed to update its " +
"parent property '"+_idForError(_parent.id())+"' (with item type '"+_parent.type()+"') " +
"using the current setter lambda!",
e
);
}
}
private @Nullable T _item() {
@Nullable T currentItem = _fetchItemFromParent();
if ( currentItem != null ) {
// We check if the type is correct
if ( !_type.isAssignableFrom(currentItem.getClass()) )
throw new IllegalArgumentException(
"The provided type of the initial value is not compatible with the actual type of the variable"
);
}
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 true;
}
@Override
public boolean isView() {
return false;
}
@Override
public final String toString() {
String value = this.mapTo(String.class, Object::toString).orElse("null");
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 = "Lens";
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(), _parent, _getter, _setter, _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 ) {
_changeListeners.fireChange(this, channel);
return this;
}
/** {@inheritDoc} */
@Override
public final Var<T> set( Channel channel, T newItem ) {
Objects.requireNonNull(channel);
if ( _setInternal(channel, newItem) )
this.fireChange(channel);
return this;
}
private boolean _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();
if ( !Objects.equals( oldValue, newValue ) ) {
// First we check if the value is compatible with the type
if ( newValue != null && !_type.isAssignableFrom(newValue.getClass()) )
throw new IllegalArgumentException(
"The provided type '"+newValue.getClass()+"' of the new value is not compatible " +
"with the expected item type '"+_type+"' of this property lens."
);
_setInParentAndInternally(channel, newValue);
return true;
}
return false;
}
@Override
public final sprouts.Observable subscribe(Observer observer ) {
_changeListeners.onChange( observer );
return this;
}
@Override
public final Observable unsubscribe(Subscriber subscriber ) {
_changeListeners.unsubscribe(subscriber);
return this;
}
public final long numberOfChangeListeners() {
return _changeListeners.numberOfChangeListeners();
}
}