diff --git a/modules/javafx.base/src/main/java/com/sun/javafx/binding/FlatMappedBinding.java b/modules/javafx.base/src/main/java/com/sun/javafx/binding/FlatMappedBinding.java new file mode 100644 index 00000000000..870ba859075 --- /dev/null +++ b/modules/javafx.base/src/main/java/com/sun/javafx/binding/FlatMappedBinding.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.javafx.binding; + +import java.util.Objects; +import java.util.function.Function; + +import javafx.beans.value.ObservableValue; + +/** + * A binding holding the value of an indirect source. The indirect source results from + * applying a mapping to the given source. + * + *

Implementation: + * + *

In a flat mapped binding there are always two subscriptions involved: + *

+ * The subscription on its given source is present when this binding itself is observed and not present otherwise. + * + *

The subscription on the indirect source must change whenever the value of the given source changes or is invalidated. More + * specifically, when the given source is invalidated the indirect subscription should be removed, and when it is revalidated it + * should resubscribe to the newly calculated indirect source. The binding avoids resubscribing when only the value of + * the indirect source changes. + * + * @param the type of the source + * @param the type of the resulting binding + */ +public class FlatMappedBinding extends LazyObjectBinding { + + private final ObservableValue source; + private final Function> mapper; + + private Subscription indirectSourceSubscription = Subscription.EMPTY; + private ObservableValue indirectSource; + + public FlatMappedBinding(ObservableValue source, Function> mapper) { + this.source = Objects.requireNonNull(source, "source cannot be null"); + this.mapper = Objects.requireNonNull(mapper, "mapper cannot be null"); + } + + @Override + protected T computeValue() { + S value = source.getValue(); + ObservableValue newIndirectSource = value == null ? null : mapper.apply(value); + + if (isObserved() && indirectSource != newIndirectSource) { // only resubscribe when observed and the indirect source changed + indirectSourceSubscription.unsubscribe(); + indirectSourceSubscription = newIndirectSource == null ? Subscription.EMPTY : Subscription.subscribeInvalidations(newIndirectSource, this::invalidate); + indirectSource = newIndirectSource; + } + + return newIndirectSource == null ? null : newIndirectSource.getValue(); + } + + @Override + protected Subscription observeSources() { + Subscription subscription = Subscription.subscribeInvalidations(source, this::invalidateAll); + + return () -> { + subscription.unsubscribe(); + unsubscribeIndirectSource(); + }; + } + + /** + * Called when the primary source changes. Invalidates this binding and unsubscribes the indirect source + * to avoid holding a strong reference to it. If the binding becomes valid later, {@link #computeValue()} will + * subscribe to a newly calculated indirect source. + * + *

Note that this only needs to be called for changes of the primary source; changes in the indirect + * source only need to invalidate this binding without also unsubscribing, as it would be wasteful to resubscribe + * to the same indirect source for each invalidation of that source. + */ + private void invalidateAll() { + unsubscribeIndirectSource(); + invalidate(); + } + + private void unsubscribeIndirectSource() { + indirectSourceSubscription.unsubscribe(); + indirectSourceSubscription = Subscription.EMPTY; + indirectSource = null; + } +} diff --git a/modules/javafx.base/src/main/java/com/sun/javafx/binding/LazyObjectBinding.java b/modules/javafx.base/src/main/java/com/sun/javafx/binding/LazyObjectBinding.java new file mode 100644 index 00000000000..d35030ec307 --- /dev/null +++ b/modules/javafx.base/src/main/java/com/sun/javafx/binding/LazyObjectBinding.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.javafx.binding; + +import javafx.beans.InvalidationListener; +import javafx.beans.binding.ObjectBinding; +import javafx.beans.value.ChangeListener; + +/** + * Extends {@link ObjectBinding} with the ability to lazily register and eagerly unregister listeners on its + * dependencies. + * + * @param the type of the wrapped {@code Object} + */ +abstract class LazyObjectBinding extends ObjectBinding { + + private Subscription subscription; + private boolean wasObserved; + + @Override + public void addListener(ChangeListener listener) { + super.addListener(listener); + + updateSubscriptionAfterAdd(); + } + + @Override + public void removeListener(ChangeListener listener) { + super.removeListener(listener); + + updateSubscriptionAfterRemove(); + } + + @Override + public void addListener(InvalidationListener listener) { + super.addListener(listener); + + updateSubscriptionAfterAdd(); + } + + @Override + public void removeListener(InvalidationListener listener) { + super.removeListener(listener); + + updateSubscriptionAfterRemove(); + } + + @Override + protected boolean allowValidation() { + return isObserved(); + } + + /** + * Called after a listener was added to start observing inputs if they're not observed already. + */ + private void updateSubscriptionAfterAdd() { + if (!wasObserved) { // was first observer registered? + subscription = observeSources(); // start observing source + + /* + * Although the act of registering a listener already attempts to make + * this binding valid, allowValidation won't allow it as the binding is + * not observed yet. This is because isObserved will not yet return true + * when the process of registering the listener hasn't completed yet. + * + * As the binding must be valid after it becomes observed the first time + * 'get' is called again. + * + * See com.sun.javafx.binding.ExpressionHelper (which is used + * by ObjectBinding) where it will do a call to ObservableValue#getValue + * BEFORE adding the actual listener. This results in ObjectBinding#get + * to be called in which the #allowValidation call will block it from + * becoming valid as the condition is "isObserved()"; this is technically + * correct as the listener wasn't added yet, but means we must call + * #get again to make this binding valid. + */ + + get(); // make binding valid as source wasn't tracked until now + wasObserved = true; + } + } + + /** + * Called after a listener was removed to stop observing inputs if this was the last listener + * observing this binding. + */ + private void updateSubscriptionAfterRemove() { + if (wasObserved && !isObserved()) { // was last observer unregistered? + subscription.unsubscribe(); + subscription = null; + invalidate(); // make binding invalid as source is no longer tracked + wasObserved = false; + } + } + + /** + * Called when this binding was previously not observed and a new observer was added. Implementors must return a + * {@link Subscription} which will be cancelled when this binding no longer has any observers. + * + * @return a {@link Subscription} which will be cancelled when this binding no longer has any observers, never null + */ + protected abstract Subscription observeSources(); +} diff --git a/modules/javafx.base/src/main/java/com/sun/javafx/binding/MappedBinding.java b/modules/javafx.base/src/main/java/com/sun/javafx/binding/MappedBinding.java new file mode 100644 index 00000000000..5eb039ac381 --- /dev/null +++ b/modules/javafx.base/src/main/java/com/sun/javafx/binding/MappedBinding.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.javafx.binding; + +import java.util.Objects; +import java.util.function.Function; + +import javafx.beans.value.ObservableValue; + +public class MappedBinding extends LazyObjectBinding { + + private final ObservableValue source; + private final Function mapper; + + public MappedBinding(ObservableValue source, Function mapper) { + this.source = Objects.requireNonNull(source, "source cannot be null"); + this.mapper = Objects.requireNonNull(mapper, "mapper cannot be null"); + } + + @Override + protected T computeValue() { + S value = source.getValue(); + + return value == null ? null : mapper.apply(value); + } + + @Override + protected Subscription observeSources() { + return Subscription.subscribeInvalidations(source, this::invalidate); // start observing source + } +} diff --git a/modules/javafx.base/src/main/java/com/sun/javafx/binding/OrElseBinding.java b/modules/javafx.base/src/main/java/com/sun/javafx/binding/OrElseBinding.java new file mode 100644 index 00000000000..fd0de0197d0 --- /dev/null +++ b/modules/javafx.base/src/main/java/com/sun/javafx/binding/OrElseBinding.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.javafx.binding; + +import java.util.Objects; + +import javafx.beans.value.ObservableValue; + +public class OrElseBinding extends LazyObjectBinding { + + private final ObservableValue source; + private final T constant; + + public OrElseBinding(ObservableValue source, T constant) { + this.source = Objects.requireNonNull(source, "source cannot be null"); + this.constant = constant; + } + + @Override + protected T computeValue() { + T value = source.getValue(); + + return value == null ? constant : value; + } + + @Override + protected Subscription observeSources() { + return Subscription.subscribeInvalidations(source, this::invalidate); // start observing source + } +} diff --git a/modules/javafx.base/src/main/java/com/sun/javafx/binding/Subscription.java b/modules/javafx.base/src/main/java/com/sun/javafx/binding/Subscription.java new file mode 100644 index 00000000000..be89bcc1e59 --- /dev/null +++ b/modules/javafx.base/src/main/java/com/sun/javafx/binding/Subscription.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.javafx.binding; + +import java.util.Objects; +import java.util.function.Consumer; + +import javafx.beans.InvalidationListener; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; + +/** + * A subscription encapsulates how to cancel it without having + * to keep track of how it was created. + * + *

For example: + * + *

{@code Subscription s = property.subscribe(System.out::println)} + * + *

The function passed in to {@code subscribe} does not need to be stored + * in order to clean up the subscription later. + */ +@FunctionalInterface +public interface Subscription { + + /** + * An empty subscription. Does nothing when cancelled. + */ + static final Subscription EMPTY = () -> {}; + + /** + * Cancels this subscription. + */ + void unsubscribe(); + + /** + * Combines this {@link Subscription} with the given {@code Subscription} + * and returns a new {@code Subscription} which will cancel both when + * cancelled. + * + * @param other another {@link Subscription}, cannot be {@code null} + * @return a combined {@link Subscription} which will cancel both when + * cancelled, never {@code null} + * @throws NullPointerException when {@code other} is {@code null} + */ + default Subscription and(Subscription other) { + Objects.requireNonNull(other); + + return () -> { + unsubscribe(); + other.unsubscribe(); + }; + } + + /** + * Creates a {@link Subscription} on this {@link ObservableValue} which + * immediately provides its current value to the given {@code subscriber}, + * followed by any subsequent changes in value. + * + * @param subscriber a {@link Consumer} to supply with the values of this + * {@link ObservableValue}, cannot be {@code null} + * @return a {@link Subscription} which can be used to cancel this + * subscription, never {@code null} + * @throws NullPointerException when {@code observableValue} or {@code subscriber} is {@code null} + */ + static Subscription subscribe(ObservableValue observableValue, Consumer subscriber) { + Objects.requireNonNull(observableValue); + Objects.requireNonNull(subscriber); + + ChangeListener listener = (obs, old, current) -> subscriber.accept(current); + + subscriber.accept(observableValue.getValue()); // eagerly send current value + observableValue.addListener(listener); + + return () -> observableValue.removeListener(listener); + } + + /** + * Creates a {@link Subscription} on this {@link ObservableValue} which + * calls the given {@code runnable} whenever this {@code ObservableValue} + * becomes invalid. + * + * @param runnable a {@link Runnable} to call whenever this + * {@link ObservableValue} becomes invalid, cannot be @{code null} + * @return a {@link Subscription} which can be used to cancel this + * subscription, never @{code null} + * @throws NullPointerException when {@code observableValue} or {@code runnable} is {@code null} + */ + static Subscription subscribeInvalidations(ObservableValue observableValue, Runnable runnable) { + Objects.requireNonNull(observableValue); + Objects.requireNonNull(runnable); + + InvalidationListener listener = obs -> runnable.run(); + + observableValue.addListener(listener); + + return () -> observableValue.removeListener(listener); + } +} diff --git a/modules/javafx.base/src/main/java/javafx/beans/binding/Bindings.java b/modules/javafx.base/src/main/java/javafx/beans/binding/Bindings.java index 45212231100..e4eb8bf2ab4 100644 --- a/modules/javafx.base/src/main/java/javafx/beans/binding/Bindings.java +++ b/modules/javafx.base/src/main/java/javafx/beans/binding/Bindings.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2021, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2010, 2022, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -437,6 +437,10 @@ public ObservableList getDependencies() { *

* Note: since 8.0, JavaBeans properties are supported and might be in the chain. *

+ *

+ * Since 19, it is recommended to use {@link ObservableValue#flatMap(java.util.function.Function)} + * to select a nested member of an {@link ObservableValue}. + *

* * @param the type of the wrapped {@code Object} * @param root @@ -444,6 +448,7 @@ public ObservableList getDependencies() { * @param steps * The property names to reach the final property * @return the created {@link ObjectBinding} + * @see ObservableValue#flatMap(java.util.function.Function) */ public static ObjectBinding select(ObservableValue root, String... steps) { return new SelectBinding.AsObject(root, steps); diff --git a/modules/javafx.base/src/main/java/javafx/beans/binding/ObjectBinding.java b/modules/javafx.base/src/main/java/javafx/beans/binding/ObjectBinding.java index 6368df52f1c..84f62a8416c 100644 --- a/modules/javafx.base/src/main/java/javafx/beans/binding/ObjectBinding.java +++ b/modules/javafx.base/src/main/java/javafx/beans/binding/ObjectBinding.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2020, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2010, 2022, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -154,7 +154,13 @@ public ObservableList getDependencies() { @Override public final T get() { if (!valid) { - value = computeValue(); + T computed = computeValue(); + + if (!allowValidation()) { + return computed; + } + + value = computed; valid = true; } return value; @@ -174,6 +180,7 @@ public final void invalidate() { valid = false; onInvalidating(); ExpressionHelper.fireValueChangedEvent(helper); + value = null; // clear cached value to avoid hard reference to stale data } } @@ -182,6 +189,35 @@ public final boolean isValid() { return valid; } + /** + * Checks if the binding has at least one listener registered on it. This + * is useful for subclasses which want to conserve resources when not observed. + * + * @return {@code true} if this binding currently has one or more + * listeners registered on it, otherwise {@code false} + * @since 19 + */ + protected final boolean isObserved() { + return helper != null; + } + + /** + * Checks if the binding is allowed to become valid. Overriding classes can + * prevent a binding from becoming valid. This is useful in subclasses which + * do not always listen for invalidations of their dependencies and prefer to + * recompute the current value instead. This can also be useful if caching of + * the current computed value is not desirable. + *

+ * The default implementation always allows bindings to become valid. + * + * @return {@code true} if this binding is allowed to become valid, otherwise + * {@code false} + * @since 19 + */ + protected boolean allowValidation() { + return true; + } + /** * Calculates the current value of this binding. *

diff --git a/modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java b/modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java index f8c8d439f99..dba9e23f399 100644 --- a/modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java +++ b/modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2020, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2010, 2022, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -25,6 +25,12 @@ package javafx.beans.value; +import java.util.function.Function; + +import com.sun.javafx.binding.FlatMappedBinding; +import com.sun.javafx.binding.MappedBinding; +import com.sun.javafx.binding.OrElseBinding; + import javafx.beans.InvalidationListener; import javafx.beans.Observable; @@ -137,4 +143,112 @@ public interface ObservableValue extends Observable { * @return The current value */ T getValue(); + + /** + * Returns an {@code ObservableValue} that holds the result of applying the + * given mapping function on this value. The result is updated when this + * {@code ObservableValue} changes. If this value is {@code null}, no + * mapping is applied and the resulting value is also {@code null}. + *

+ * For example, mapping a string to an upper case string: + *

{@code
+     * var text = new SimpleStringProperty("abcd");
+     * ObservableValue upperCase = text.map(String::toUpperCase);
+     *
+     * upperCase.getValue();  // Returns "ABCD"
+     * text.set("xyz");
+     * upperCase.getValue();  // Returns "XYZ"
+     * text.set(null);
+     * upperCase.getValue();  // Returns null
+     * }
+ * + * @param the type of values held by the resulting {@code ObservableValue} + * @param mapper the mapping function to apply to a value, cannot be {@code null} + * @return an {@code ObservableValue} that holds the result of applying the given + * mapping function on this value, or {@code null} when it + * is {@code null}; never returns {@code null} + * @throws NullPointerException if the mapping function is {@code null} + * @since 19 + */ + default ObservableValue map(Function mapper) { + return new MappedBinding<>(this, mapper); + } + + /** + * Returns an {@code ObservableValue} that holds this value, or the given constant if + * it is {@code null}. The result is updated when this {@code ObservableValue} changes. This + * method, when combined with {@link #map(Function)}, allows handling of all values + * including {@code null} values. + *

+ * For example, mapping a string to an upper case string, but leaving it blank + * if the input is {@code null}: + *

{@code
+     * var text = new SimpleStringProperty("abcd");
+     * ObservableValue upperCase = text.map(String::toUpperCase).orElse("");
+     *
+     * upperCase.getValue();  // Returns "ABCD"
+     * text.set(null);
+     * upperCase.getValue();  // Returns ""
+     * }
+ * + * @param constant the value to use when this {@code ObservableValue} + * holds {@code null}; can be {@code null} + * @return an {@code ObservableValue} that holds this value, or the given constant if + * it is {@code null}; never returns {@code null} + * @since 19 + */ + default ObservableValue orElse(T constant) { + return new OrElseBinding<>(this, constant); + } + + /** + * Returns an {@code ObservableValue} that holds the value of an {@code ObservableValue} + * produced by applying the given mapping function on this value. The result is updated + * when either this {@code ObservableValue} or the {@code ObservableValue} produced by + * the mapping changes. If this value is {@code null}, no mapping is applied and the + * resulting value is {@code null}. If the mapping resulted in {@code null}, then the + * resulting value is also {@code null}. + *

+ * This method is similar to {@link #map(Function)}, but the mapping function is + * one whose result is already an {@code ObservableValue}, and if invoked, {@code flatMap} does + * not wrap it within an additional {@code ObservableValue}. + *

+ * For example, a property that is only {@code true} when a UI element is part of a {@code Scene} + * that is part of a {@code Window} that is currently shown on screen: + *

{@code
+     * ObservableValue isShowing = listView.sceneProperty()
+     *     .flatMap(Scene::windowProperty)
+     *     .flatMap(Window::showingProperty)
+     *     .orElse(false);
+     *
+     * // Assuming the listView is currently shown to the user, then:
+     *
+     * isShowing().getValue();  // Returns true
+     *
+     * listView.getScene().getWindow().hide();
+     * isShowing().getValue();  // Returns false
+     *
+     * listView.getScene().getWindow().show();
+     * isShowing().getValue();  // Returns true
+     *
+     * listView.getParent().getChildren().remove(listView);
+     * isShowing().getValue();  // Returns false
+     * }
+ * Changes in any of the values of: the scene of {@code listView}, the window of that scene, or + * the showing of that window, will update the boolean value {@code isShowing}. + *

+ * This method is preferred over {@link javafx.beans.binding.Bindings#select Bindings} methods + * since it is type safe. + * + * @param the type of values held by the resulting {@code ObservableValue} + * @param mapper the mapping function to apply to a value, cannot be {@code null} + * @return an {@code ObservableValue} that holds the value of an {@code ObservableValue} + * produced by applying the given mapping function on this value, or + * {@code null} when the value is {@code null}; never returns {@code null} + * @throws NullPointerException if the mapping function is {@code null} + * @since 19 + */ + default ObservableValue flatMap(Function> mapper) { + return new FlatMappedBinding<>(this, mapper); + } } diff --git a/modules/javafx.base/src/shims/java/com/sun/javafx/binding/LazyObjectBindingStub.java b/modules/javafx.base/src/shims/java/com/sun/javafx/binding/LazyObjectBindingStub.java new file mode 100644 index 00000000000..55e58d0b23a --- /dev/null +++ b/modules/javafx.base/src/shims/java/com/sun/javafx/binding/LazyObjectBindingStub.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.javafx.binding; + +/** + * Stub to allow testing of package private LazyObjectBinding. + */ +public class LazyObjectBindingStub extends LazyObjectBinding { + + public int computeValueCalls; + public int startObservingCalls; + public int stopObservingCalls; + + @Override + protected T computeValue() { + computeValueCalls++; + + return null; + } + + @Override + protected Subscription observeSources() { + startObservingCalls++; + + return () -> { + stopObservingCalls++; + }; + } +} diff --git a/modules/javafx.base/src/test/java/test/javafx/beans/value/LazyObjectBindingTest.java b/modules/javafx.base/src/test/java/test/javafx/beans/value/LazyObjectBindingTest.java new file mode 100644 index 00000000000..72da81b3c26 --- /dev/null +++ b/modules/javafx.base/src/test/java/test/javafx/beans/value/LazyObjectBindingTest.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package test.javafx.beans.value; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.sun.javafx.binding.LazyObjectBindingStub; + +import javafx.beans.InvalidationListener; +import javafx.beans.value.ChangeListener; + +public class LazyObjectBindingTest { + + private LazyObjectBindingStub binding = new LazyObjectBindingStub<>(); + + void resetCounters() { + binding.startObservingCalls = 0; + binding.computeValueCalls = 0; + binding.stopObservingCalls = 0; + } + + @Test + void shouldBeInvalidInitially() { + assertFalse(binding.isValid()); + } + + @Nested + class WhenObservedWithInvalidationListener { + private InvalidationListener invalidationListener = obs -> {}; + + { + binding.addListener(invalidationListener); + } + + @Test + void shouldBeValid() { + assertTrue(binding.isValid()); + } + + @Test + void shouldStartObservingSource() { + assertEquals(1, binding.startObservingCalls); + } + + @Test + void shouldNotStopObservingSource() { + assertEquals(0, binding.stopObservingCalls); + } + + @Test + void shouldCallComputeValueOneOrTwoTimes() { + + /* + * The binding is made valid twice currently, once when + * the listener is registered, and again after the observing of + * inputs starts. The first time the binding does not become + * valid because it is not yet considered "observed" as the + * computeValue call occurs in the middle of the listener + * registration process. + * + * See also the explanation in LazyObjectBinding#updateSubcriptionAfterAdd + */ + + assertTrue(binding.computeValueCalls >= 1 && binding.computeValueCalls <= 2); + } + + @Nested + class AndWhenObservedAgain { + private ChangeListener changeListener = (obs, old, current) -> {}; + + { + resetCounters(); + binding.addListener(changeListener); + } + + @Test + void shouldStillBeValid() { + assertTrue(binding.isValid()); + } + + @Test + void shouldNotStartObservingSourceAgain() { + assertEquals(0, binding.startObservingCalls); + } + + @Test + void shouldNotStopObservingSource() { + assertEquals(0, binding.stopObservingCalls); + } + + @Test + void shouldNotComputeValueAgain() { + assertEquals(0, binding.computeValueCalls); + } + + @Nested + class AndThenOneObserverIsRemoved { + { + resetCounters(); + binding.removeListener(changeListener); + } + + @Test + void shouldStillBeValid() { + assertTrue(binding.isValid()); + } + + @Test + void shouldNotStartObservingSourceAgain() { + assertEquals(0, binding.startObservingCalls); + } + + @Test + void shouldNotStopObservingSource() { + assertEquals(0, binding.stopObservingCalls); + } + + @Test + void shouldNotComputeValueAgain() { + assertEquals(0, binding.computeValueCalls); + } + + @Nested + class AndThenTheLastObserverIsRemoved { + { + resetCounters(); + binding.removeListener(invalidationListener); + } + + @Test + void shouldNotStartObservingSource() { + assertEquals(0, binding.startObservingCalls); + } + + @Test + void shouldStopObservingSource() { + assertEquals(1, binding.stopObservingCalls); + } + + @Test + void shouldNotComputeValue() { + assertEquals(0, binding.computeValueCalls); + } + + @Test + void shouldNoLongerBeValid() { + assertFalse(binding.isValid()); + } + + @Nested + class AndTheListenerIsRemovedAgain { + { + resetCounters(); + binding.removeListener(invalidationListener); + } + + @Test + void shouldNotStartObservingSource() { + assertEquals(0, binding.startObservingCalls); + } + + @Test + void shouldNotStopObservingSource() { + assertEquals(0, binding.stopObservingCalls); + } + + @Test + void shouldNotComputeValue() { + assertEquals(0, binding.computeValueCalls); + } + + @Test + void shouldNotBeValid() { + assertFalse(binding.isValid()); + } + } + } + } + } + } +} diff --git a/modules/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java b/modules/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java new file mode 100644 index 00000000000..b6722f7dad5 --- /dev/null +++ b/modules/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java @@ -0,0 +1,987 @@ +/* + * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package test.javafx.beans.value; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import javafx.beans.InvalidationListener; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; + +public class ObservableValueFluentBindingsTest { + private int invalidations; + + private final StringProperty property = new SimpleStringProperty("Initial"); + private final List values = new ArrayList<>(); + private final ChangeListener changeListener = (obs, old, current) -> values.add(current); + private final InvalidationListener invalidationListener = obs -> invalidations++; + + @Nested + class When_map_Called { + + @Nested + class WithNull { + + @Test + void shouldThrowNullPointerException() { + assertThrows(NullPointerException.class, () -> property.map(null)); + } + } + + @Nested + class WithNotNullReturns_ObservableValue_Which { + private ObservableValue observableValue = property.map(v -> v + "+map"); + + @Test + void shouldNotBeNull() { + assertNotNull(observableValue); + } + + @Test + void shouldNotBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class When_getValue_Called { + + @Test + void shouldReturnPropertyValuesWithOperationApplied() { + assertEquals("Initial+map", observableValue.getValue()); + + property.set("Left"); + + assertEquals("Left+map", observableValue.getValue()); + } + + @Test + void shouldNotOperateOnNull() { + property.set(null); + + assertEquals((String) null, observableValue.getValue()); + } + } + + @Nested + class WhenObservedForInvalidations { + { + startObservingInvalidations(observableValue); + } + + @Test + void shouldOnlyInvalidateOnce() { + assertNotInvalidated(); + + property.set("Left"); + + assertInvalidated(); + + property.set("Right"); + + assertNotInvalidated(); + } + + @Test + void shouldBeStronglyReferenced() { + ReferenceAsserts.testIfStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class AndWhenUnobserved { + { + stopObservingInvalidations(observableValue); + } + + @Test + void shouldNoLongerBeCalled() { + assertNotInvalidated(); + + property.set("Left"); + property.set("Right"); + + assertNotInvalidated(); + } + + @Test + void shouldNoLongerBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + } + } + + @Nested + class WhenObservedForChanges { + { + startObservingChanges(observableValue); + } + + @Test + void shouldApplyOperation() { + assertNothingIsObserved(); + + property.set("Right"); + + assertObserved("Right+map"); + } + + @Test + void shouldNotOperateOnNull() { + property.set(null); + + assertObserved((String) null); // map operation is skipped (as it would NPE otherwise) and the resulting value is null + } + + @Test + void shouldBeStronglyReferenced() { + ReferenceAsserts.testIfStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class AndWhenUnobserved { + { + stopObservingChanges(observableValue); + } + + @Test + void shouldNoLongerBeCalled() { + assertNothingIsObserved(); + + property.set("Right"); + + assertNothingIsObserved(); + } + + @Test + void shouldNoLongerBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + } + } + + @Nested + class When_orElse_CalledReturns_ObservableValue_Which { + { + observableValue = observableValue.orElse("Empty"); + } + + @Test + void shouldNotBeNull() { + assertNotNull(observableValue); + } + + @Test + void shouldNotBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class WhenObservedForChanges { + { + startObservingChanges(observableValue); + } + + @Test + void shouldApplyMapThenOrElseOperation() { + assertNothingIsObserved(); + + property.set("Left"); + + assertObserved("Left+map"); + + property.set(null); + + assertObserved("Empty"); + } + + @Test + void shouldBeStronglyReferenced() { + ReferenceAsserts.testIfStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class AndWhenUnobserved { + { + stopObservingChanges(observableValue); + } + + @Test + void shouldNoLongerBeCalled() { + assertNothingIsObserved(); + + property.set("Left"); + + assertNothingIsObserved(); + } + + @Test + void shouldNoLongerBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + } + } + } + + @Nested + class When_map_CalledAgainReturns_ObservableValue_Which { + { + observableValue = observableValue.map(v -> v + "+map2"); + } + + @Test + void shouldNotBeNull() { + assertNotNull(observableValue); + } + + @Test + void shouldNotBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class WhenObservedForChanges { + { + startObservingChanges(observableValue); + } + + @Test + void shouldApplyMapThenSecondMapOperation() { + assertNothingIsObserved(); + + property.set("Left"); + + assertObserved("Left+map+map2"); + + property.set(null); + + assertObserved((String) null); + } + + @Test + void shouldBeStronglyReferenced() { + ReferenceAsserts.testIfStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class AndWhenUnobserved { + { + stopObservingChanges(observableValue); + } + + @Test + void shouldNoLongerBeCalled() { + assertNothingIsObserved(); + + property.set("Left"); + + assertNothingIsObserved(); + } + + @Test + void shouldNoLongerBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + } + } + } + } + } + + @Nested + class When_orElse_CalledReturns_ObservableValue_Which { + private ObservableValue observableValue = property.orElse("Empty"); + + @Test + void shouldNotBeNull() { + assertNotNull(observableValue); + } + + @Test + void shouldNotBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class When_getValue_Called { + + @Test + void shouldReturnPropertyValuesWithOperationApplied() { + assertEquals("Initial", observableValue.getValue()); + + property.set(null); + + assertEquals("Empty", observableValue.getValue()); + } + } + + @Nested + class WhenObservedForInvalidations { + { + startObservingInvalidations(observableValue); + } + + @Test + void shouldOnlyInvalidateOnce() { + assertNotInvalidated(); + + property.set("Left"); + + assertInvalidated(); + + property.set(null); + + assertNotInvalidated(); + } + + @Test + void shouldBeStronglyReferenced() { + ReferenceAsserts.testIfStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class AndWhenUnobserved { + { + stopObservingInvalidations(observableValue); + } + + @Test + void shouldNoLongerBeCalled() { + assertNotInvalidated(); + + property.set("Left"); + property.set(null); + + assertNotInvalidated(); + } + + @Test + void shouldNoLongerBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + } + } + + @Nested + class WhenObservedForChanges { + { + startObservingChanges(observableValue); + } + + @Test + void shouldApplyOperation() { + assertNothingIsObserved(); + + property.set("Left"); + + assertObserved("Left"); + + property.set(null); + + assertObserved("Empty"); + } + + @Test + void shouldBeStronglyReferenced() { + ReferenceAsserts.testIfStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class AndWhenUnobserved { + { + stopObservingChanges(observableValue); + } + + @Test + void shouldNoLongerBeCalled() { + assertNothingIsObserved(); + + property.set("Left"); + + assertNothingIsObserved(); + } + + @Test + void shouldNoLongerBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + } + } + } + + @Nested + class When_flatMap_Called { + + @Nested + class WithNull { + + @Test + void shouldThrowNullPointerException() { + assertThrows(NullPointerException.class, () -> property.flatMap(null)); + } + } + + @Nested + class WithNotNullReturns_ObservableValue_Which { + private int subscribeCount; + private int unsubscribeCount; + + private StringProperty left = new SimpleStringProperty("LEFT"); + private StringProperty right = new SimpleStringProperty("RIGHT"); + private StringProperty unknown = new SimpleStringProperty("UNKNOWN") { + @Override + public void addListener(InvalidationListener listener) { + super.addListener(listener); + + subscribeCount++; + }; + + @Override + public void removeListener(InvalidationListener listener) { + super.removeListener(listener); + + unsubscribeCount++; + }; + }; + + private ObservableValue observableValue = + property.flatMap(v -> "Left".equals(v) ? left : "Right".equals(v) ? right : unknown); + + @Test + void shouldNotBeNull() { + assertNotNull(observableValue); + } + + @Test + void shouldNotBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class When_getValue_Called { + + @Test + void shouldReturnPropertyValuesWithOperationApplied() { + assertEquals("UNKNOWN", observableValue.getValue()); // initially it is not left or right, so unknown + + property.set("Right"); + + assertEquals("RIGHT", observableValue.getValue()); + + right.setValue("RIGHT+1"); + + assertEquals("RIGHT+1", observableValue.getValue()); + + left.setValue("LEFT+1"); + unknown.setValue("UNKNOWN+1"); + + assertEquals("RIGHT+1", observableValue.getValue()); // changing left or unknown value should have no effect + + property.set("Left"); + + assertEquals("LEFT+1", observableValue.getValue()); // after switching to left, it switches to the left value + } + + @Test + void shouldNotOperateOnNull() { + property.set(null); + + assertNull(observableValue.getValue()); + } + + @Test + void shouldIgnoreFlatMapsToNull() { + unknown = null; + + assertNull(observableValue.getValue()); + } + } + + @Nested + class WhenObservedForInvalidations { + { + startObservingInvalidations(observableValue); + } + + @Test + void shouldOnlyInvalidateOnce() { + assertNotInvalidated(); + + unknown.set("UNKNOWN+1"); + + assertInvalidated(); + + property.set("Right"); + + assertNotInvalidated(); + } + + @Test + void shouldNotResubscribeToMappedPropertyOnEachValidation() { + assertEquals(1, subscribeCount); + assertEquals(0, unsubscribeCount); + + unknown.set("A"); + observableValue.getValue(); + unknown.set("B"); + observableValue.getValue(); + unknown.set("C"); + observableValue.getValue(); + + assertEquals(1, subscribeCount); + assertEquals(0, unsubscribeCount); + } + + @Test + void shouldBeStronglyReferenced() { + ReferenceAsserts.testIfStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Test + void shouldStronglyReferMappedProperty() { + ReferenceAsserts.testIfStronglyReferenced(unknown, () -> unknown = null); + } + + @Test + void shouldNotStronglyReferOldMappedProperty() { + property.set("Right"); + + ReferenceAsserts.testIfNotStronglyReferenced(unknown, () -> unknown = null); + } + + @Nested + class AndWhenUnobserved { + { + stopObservingInvalidations(observableValue); + } + + @Test + void shouldNoLongerBeCalled() { + assertNotInvalidated(); + + property.set("Left"); + property.set("Right"); + property.set("Unknown"); + + assertNotInvalidated(); + } + + @Test + void shouldNoLongerBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + ReferenceAsserts.testIfNotStronglyReferenced(unknown, () -> unknown = null); + } + } + } + + @Nested + class WhenObservedForChanges { + { + startObservingChanges(observableValue); + } + + @Test + void shouldApplyOperation() { + assertNothingIsObserved(); + + unknown.set("UNKNOWN+1"); + + assertObserved("UNKNOWN+1"); // as it initially is unknown, changing the unknown property results in a change + + property.set("Right"); + + assertObserved("RIGHT"); // switching to right gives the value of the right property + + unknown.set("UNKNOWN+2"); + left.set("LEFT+1"); + + assertNothingIsObserved(); // changing left or unknown has no effect when currently observing right + + right.set("RIGHT+1"); + + assertObserved("RIGHT+1"); // changing right value has an effect as right is observed + + property.set("Left"); + + assertObserved("LEFT+1"); // switching to left sees latest left value + } + + @Test + void shouldNotResubscribeToMappedPropertyOnEachValidation() { + assertEquals(1, subscribeCount); + assertEquals(0, unsubscribeCount); + + unknown.set("A"); + unknown.set("B"); + unknown.set("C"); + + assertEquals(1, subscribeCount); + assertEquals(0, unsubscribeCount); + } + + @Test + void shouldNotOperateOnNull() { + property.set(null); + + assertObserved((String)null); // flatMap operation is skipped (as it would NPE otherwise) and the resulting value is null + } + + @Test + void shouldIgnoreFlatMapsToNull() { + right = null; + + property.set("Right"); + + assertObserved((String)null); // flatMap maps to right property which is now null, this results in null + } + + @Test + void shouldObserveNullWhenFlatMappedPropertyIsSetToNull() { + property.set("Right"); + + assertObserved("RIGHT"); + + property.set(null); + + assertObserved((String)null); + } + + @Test + void shouldBeStronglyReferenced() { + ReferenceAsserts.testIfStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Test + void shouldStronglyReferMappedProperty() { + ReferenceAsserts.testIfStronglyReferenced(unknown, () -> unknown = null); + } + + @Test + void shouldNotStronglyReferOldMappedProperty() { + property.set("Right"); + + ReferenceAsserts.testIfNotStronglyReferenced(unknown, () -> unknown = null); + } + + @Nested + class AndWhenUnobserved { + { + stopObservingChanges(observableValue); + } + + @Test + void shouldNoLongerBeCalled() { + assertNothingIsObserved(); + + property.set("Left"); + property.set("Right"); + property.set("Unknown"); + + assertNothingIsObserved(); + } + + @Test + void shouldNoLongerBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + ReferenceAsserts.testIfNotStronglyReferenced(unknown, () -> unknown = null); + } + } + } + + @Nested + class When_map_CalledReturns_ObservableValue_Which { + { + observableValue = observableValue.map(v -> v + "+map"); + } + + @Test + void shouldNotBeNull() { + assertNotNull(observableValue); + } + + @Test + void shouldNotBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class WhenObservedForChanges { + { + startObservingChanges(observableValue); + } + + @Test + void shouldApplyFlatMapThenMapOperation() { + assertNothingIsObserved(); + + property.set("Left"); + + assertObserved("LEFT+map"); + + property.set("Right"); + + assertObserved("RIGHT+map"); + + left.set("LEFT-LEFT"); // should have no effect + + assertNothingIsObserved(); + + right.set("RIGHT-RIGHT"); + + assertObserved("RIGHT-RIGHT+map"); + } + + @Test + void shouldBeStronglyReferenced() { + ReferenceAsserts.testIfStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class AndWhenUnobserved { + { + stopObservingChanges(observableValue); + } + + @Test + void shouldNoLongerBeCalled() { + assertNothingIsObserved(); + + property.set("Left"); + + assertNothingIsObserved(); + } + + @Test + void shouldNoLongerBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + } + } + } + + @Nested + class When_orElse_CalledReturns_ObservableValue_Which { + { + observableValue = observableValue.orElse("Empty"); + } + + @Test + void shouldNotBeNull() { + assertNotNull(observableValue); + } + + @Test + void shouldNotBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class WhenObservedForChanges { + { + startObservingChanges(observableValue); + } + + @Test + void shouldApplyFlatMapThenMapOperation() { + assertNothingIsObserved(); + + property.set("Left"); + + assertObserved("LEFT"); + + property.set("Right"); + + assertObserved("RIGHT"); + + left.set("LEFT-LEFT"); // should have no effect as right branch is observed + + assertNothingIsObserved(); + + right.set("RIGHT-RIGHT"); + + assertObserved("RIGHT-RIGHT"); + + right.set(null); + + assertObserved("Empty"); + + property.set("Left"); + + assertObserved("LEFT-LEFT"); + + property.set(null); + + assertObserved("Empty"); + } + + @Test + void shouldBeStronglyReferenced() { + ReferenceAsserts.testIfStronglyReferenced(observableValue, () -> observableValue = null); + } + + @Nested + class AndWhenUnobserved { + { + stopObservingChanges(observableValue); + } + + @Test + void shouldNoLongerBeCalled() { + assertNothingIsObserved(); + + property.set("Left"); + + assertNothingIsObserved(); + } + + @Test + void shouldNoLongerBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> observableValue = null); + } + } + } + } + } + } + + /** + * Ensures nothing has been observed since the last check. + */ + private void assertNothingIsObserved() { + assertObserved(); + } + + /** + * Ensures that given values have been observed since last call. + * + * @param expectedValues an array of expected values + */ + private void assertObserved(String... expectedValues) { + assertEquals(values, Arrays.asList(expectedValues)); + values.clear(); + } + + /** + * Starts observing the given observable value for changes. This will do + * a sanity check that the observer is currently not working before adding it. + * + * @param observableValue an {@code ObservableValue}, cannot be {@code null} + */ + private void startObservingChanges(ObservableValue observableValue) { + values.clear(); + + property.setValue("Left"); + property.setValue("Right"); + property.setValue("Initial"); + + assertTrue(values.isEmpty()); + + observableValue.addListener(changeListener); + } + + /** + * Stops observing the given observable value for changes. This will do a + * sanity check that the observer is currently working before removing it. + * + * @param observableValue an {@code ObservableValue}, cannot be {@code null} + */ + private void stopObservingChanges(ObservableValue observableValue) { + values.clear(); + + property.setValue("Left"); + property.setValue("Right"); + + assertEquals(2, values.size()); + + values.clear(); + + observableValue.removeListener(changeListener); + } + + /** + * Ensures no invalidations occurred since the last check. + */ + private void assertNotInvalidated() { + assertEquals(0, invalidations); + } + + /** + * Ensures that an invalidation occurred since last check. + */ + private void assertInvalidated() { + assertEquals(1, invalidations); + invalidations = 0; + } + + /** + * Starts observing the given observable value for invalidations. This will do + * a sanity check that the observer is currently not working before adding it. + * + * @param observableValue an {@code ObservableValue}, cannot be {@code null} + */ + private void startObservingInvalidations(ObservableValue observableValue) { + invalidations = 0; + + property.getValue(); + property.setValue("Left"); + property.setValue("Right"); + property.setValue("Initial"); + + assertNotInvalidated(); + + observableValue.addListener(invalidationListener); + } + + /** + * Stops observing the given observable value for invalidations. This will do a + * sanity check that the observer is currently working before removing it. + * + * @param observableValue an {@code ObservableValue}, cannot be {@code null} + */ + private void stopObservingInvalidations(ObservableValue observableValue) { + invalidations = 0; + + property.getValue(); + property.setValue("Left"); + property.setValue("Right"); + property.setValue("Initial"); + + assertInvalidated(); + + observableValue.removeListener(invalidationListener); + } +} diff --git a/modules/javafx.base/src/test/java/test/javafx/beans/value/ReferenceAsserts.java b/modules/javafx.base/src/test/java/test/javafx/beans/value/ReferenceAsserts.java new file mode 100644 index 00000000000..a04691ef3cf --- /dev/null +++ b/modules/javafx.base/src/test/java/test/javafx/beans/value/ReferenceAsserts.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package test.javafx.beans.value; + +import java.lang.ref.WeakReference; + +import test.util.memory.JMemoryBuddy; + +public class ReferenceAsserts { + + public static void testIfStronglyReferenced(Object obj, Runnable clearRefs) { + WeakReference ref = new WeakReference<>(obj); + + clearRefs.run(); + obj = null; + + JMemoryBuddy.assertNotCollectable(ref); + } + + public static void testIfNotStronglyReferenced(Object obj, Runnable clearRefs) { + WeakReference ref = new WeakReference<>(obj); + + clearRefs.run(); + obj = null; + + JMemoryBuddy.assertCollectable(ref); + } +}