diff --git a/rhino/src/main/java/org/mozilla/javascript/LambdaAccessorSlot.java b/rhino/src/main/java/org/mozilla/javascript/LambdaAccessorSlot.java new file mode 100644 index 0000000000..18538473c5 --- /dev/null +++ b/rhino/src/main/java/org/mozilla/javascript/LambdaAccessorSlot.java @@ -0,0 +1,129 @@ +package org.mozilla.javascript; + +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * A specialized property accessor using lambda functions, similar to {@link LambdaSlot}, but allows + * defining properties with getter and setter lambdas that require access to the owner object + * ('this'). This enables the implementation of properties that can access instance fields of the + * owner. + * + *

Unlike {@link LambdaSlot}, Lambda functions used to define getter and setter logic require the + * owner's `Scriptable` object as one of the parameters. This is particularly useful for + * implementing properties that behave like standard JavaScript properties, but are implemented with + * native functionality without the need for reflection. + */ +public class LambdaAccessorSlot extends Slot { + private transient Function getter; + private transient BiConsumer setter; + private LambdaFunction getterFunction; + private LambdaFunction setterFunction; + + LambdaAccessorSlot(Object name, int index) { + super(name, index, 0); + } + + LambdaAccessorSlot(Slot oldSlot) { + super(oldSlot); + } + + @Override + boolean isValueSlot() { + return false; + } + + @Override + boolean isSetterSlot() { + return true; + } + + @Override + ScriptableObject getPropertyDescriptor(Context cx, Scriptable scope) { + ScriptableObject desc = (ScriptableObject) cx.newObject(scope); + + int attr = getAttributes(); + boolean es6 = cx.getLanguageVersion() >= Context.VERSION_ES6; + if (es6) { + if (getterFunction == null && setterFunction == null) { + desc.defineProperty( + "writable", + (attr & ScriptableObject.READONLY) == 0, + ScriptableObject.EMPTY); + } + } else { + desc.setCommonDescriptorProperties( + attr, getterFunction == null && setterFunction == null); + } + + if (getterFunction != null) { + desc.defineProperty("get", this.getterFunction, ScriptableObject.EMPTY); + } + + if (setterFunction != null) { + desc.defineProperty("set", this.setterFunction, ScriptableObject.EMPTY); + } else if (es6) { + desc.defineProperty("set", Undefined.instance, ScriptableObject.EMPTY); + } + + if (es6) { + desc.defineProperty( + "enumerable", (attr & ScriptableObject.DONTENUM) == 0, ScriptableObject.EMPTY); + desc.defineProperty( + "configurable", + (attr & ScriptableObject.PERMANENT) == 0, + ScriptableObject.EMPTY); + } + return desc; + } + + @Override + public boolean setValue(Object value, Scriptable scope, Scriptable start, boolean isThrow) { + if (setter == null) { + if (getter != null) { + throwNoSetterException(start, value); + return true; + } + } else { + setter.accept(start, value); + return true; + } + + return super.setValue(value, start, start, isThrow); + } + + @Override + public Object getValue(Scriptable owner) { + if (getter != null) { + return getter.apply(owner); + } + return super.getValue(owner); + } + + public void setGetter(Scriptable scope, Function getter) { + this.getter = getter; + if (getter != null) { + this.getterFunction = + new LambdaFunction( + scope, + "get " + super.name, + 0, + (cx1, scope1, thisObj, args) -> getter.apply(thisObj)); + } + } + + public void setSetter(Scriptable scope, BiConsumer setter) { + this.setter = setter; + if (setter != null) { + this.setterFunction = + new LambdaFunction( + scope, + "set " + super.name, + 1, + (cx1, scope1, thisObj, args) -> { + setter.accept(thisObj, args[0]); + return Undefined.instance; + }); + } + } +} diff --git a/rhino/src/main/java/org/mozilla/javascript/LambdaConstructor.java b/rhino/src/main/java/org/mozilla/javascript/LambdaConstructor.java index 03c36c0b22..d0a8c0e86e 100644 --- a/rhino/src/main/java/org/mozilla/javascript/LambdaConstructor.java +++ b/rhino/src/main/java/org/mozilla/javascript/LambdaConstructor.java @@ -6,6 +6,9 @@ package org.mozilla.javascript; +import java.util.function.BiConsumer; +import java.util.function.Function; + /** * This class implements a JavaScript function that may be used as a constructor by delegating to an * interface that can be easily implemented as a lambda. The LambdaFunction class may be used to add @@ -120,6 +123,25 @@ public void definePrototypeProperty(Symbol key, Object value, int attributes) { proto.defineProperty(key, value, attributes); } + public void definePrototypeProperty( + Context cx, + String name, + java.util.function.Function getter, + int attributes) { + ScriptableObject proto = getPrototypeScriptable(); + proto.defineProperty(cx, name, getter, null, attributes); + } + + public void definePrototypeProperty( + Context cx, + String name, + Function getter, + BiConsumer setter, + int attributes) { + ScriptableObject proto = getPrototypeScriptable(); + proto.defineProperty(cx, name, getter, setter, attributes); + } + /** * Define a function property directly on the constructor that is implemented under the covers * by a LambdaFunction. diff --git a/rhino/src/main/java/org/mozilla/javascript/ScriptableObject.java b/rhino/src/main/java/org/mozilla/javascript/ScriptableObject.java index abe2881e6e..a01c62674f 100644 --- a/rhino/src/main/java/org/mozilla/javascript/ScriptableObject.java +++ b/rhino/src/main/java/org/mozilla/javascript/ScriptableObject.java @@ -28,6 +28,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Supplier; import org.mozilla.javascript.ScriptRuntime.StringIdOrIndex; @@ -1690,6 +1691,63 @@ public void defineProperty( slot.setter = setter; } + /** + * Define a property on this object that is implemented using lambda functions accepting + * Scriptable `this` object as first parameter. Unlike with `defineProperty(String name, + * Supplier getter, Consumer setter, int attributes)` where getter and setter + * need to have access to target object instance, this allows for defining properties on + * LambdaConstructor prototype providing getter and setter logic with java instance methods. If + * a property with the same name already exists, then it will be replaced. This property will + * appear to the JavaScript user exactly like descriptor with a getter and setter, just as if + * they had been defined in JavaScript using Object.defineOwnProperty. + * + * @param name the name of the property + * @param getter a function that given Scriptable `this` returns the value of the property. If + * null, throws typeError + * @param setter a function that Scriptable `this` and a value sets the value of the property, + * by calling appropriate method on `this`. If null, then the value will be set directly and + * may not be retrieved by the getter. + * @param attributes the attributes to set on the property + */ + public void defineProperty( + Context cx, + String name, + java.util.function.Function getter, + BiConsumer setter, + int attributes) { + if (getter == null && setter == null) + throw ScriptRuntime.typeError("at least one of {getter, setter} is required"); + + slotMap.compute( + name, + 0, + (id, index, existing) -> + ensureLambdaAccessorSlot( + cx, id, index, existing, getter, setter, attributes)); + } + + private LambdaAccessorSlot createLambdaAccessorSlot( + Object name, + int index, + Slot existing, + java.util.function.Function getter, + BiConsumer setter, + int attributes) { + LambdaAccessorSlot slot; + if (existing == null) { + slot = new LambdaAccessorSlot(name, index); + } else if (existing instanceof LambdaAccessorSlot) { + slot = (LambdaAccessorSlot) existing; + } else { + slot = new LambdaAccessorSlot(existing); + } + + slot.setGetter(this, getter); + slot.setSetter(this, setter); + slot.setAttributes(attributes); + return slot; + } + protected void checkPropertyDefinition(ScriptableObject desc) { Object getter = getProperty(desc, "get"); if (getter != NOT_FOUND && getter != Undefined.instance && !(getter instanceof Callable)) { @@ -2695,6 +2753,33 @@ private static LambdaSlot ensureLambdaSlot(Object name, int index, Slot existing } } + private LambdaAccessorSlot ensureLambdaAccessorSlot( + Context cx, + Object name, + int index, + Slot existing, + java.util.function.Function getter, + BiConsumer setter, + int attributes) { + var newSlot = createLambdaAccessorSlot(name, index, existing, getter, setter, attributes); + var newDesc = newSlot.getPropertyDescriptor(cx, this); + checkPropertyDefinition(newDesc); + + if (existing == null) { + checkPropertyChange(name, null, newDesc); + return newSlot; + } else if (existing instanceof LambdaAccessorSlot) { + var slot = (LambdaAccessorSlot) existing; + var existingDesc = slot.getPropertyDescriptor(cx, this); + checkPropertyChange(name, existingDesc, newDesc); + return newSlot; + } else { + var existingDesc = existing.getPropertyDescriptor(cx, this); + checkPropertyChange(name, existingDesc, newDesc); + return newSlot; + } + } + private void writeObject(ObjectOutputStream out) throws IOException { out.defaultWriteObject(); final long stamp = slotMap.readLock(); diff --git a/tests/src/test/java/org/mozilla/javascript/tests/LambdaAccessorSlotTest.java b/tests/src/test/java/org/mozilla/javascript/tests/LambdaAccessorSlotTest.java new file mode 100644 index 0000000000..92b696a04a --- /dev/null +++ b/tests/src/test/java/org/mozilla/javascript/tests/LambdaAccessorSlotTest.java @@ -0,0 +1,524 @@ +package org.mozilla.javascript.tests; + +import static org.junit.Assert.*; +import static org.mozilla.javascript.ScriptableObject.*; +import static org.mozilla.javascript.tests.LambdaAccessorSlotTest.StatusHolder.self; + +import org.junit.Test; +import org.mozilla.javascript.EcmaError; +import org.mozilla.javascript.LambdaConstructor; +import org.mozilla.javascript.ScriptRuntime; +import org.mozilla.javascript.Scriptable; +import org.mozilla.javascript.ScriptableObject; +import org.mozilla.javascript.Undefined; + +public class LambdaAccessorSlotTest { + @Test + public void testGetterProperty() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + StatusHolder.init(scope) + .definePrototypeProperty( + cx, + "status", + (thisObj) -> self(thisObj).getStatus(), + (thisObj, value) -> self(thisObj).setStatus(value), + DONTENUM); + + Object getterResult = + cx.evaluateString( + scope, + "s = new StatusHolder('InProgress'); s.status", + "source", + 1, + null); + assertEquals("InProgress", getterResult); + return null; + }); + } + + @Test + public void testThrowIfNeitherGetterOrSetterAreDefined() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + var error = + assertThrows( + EcmaError.class, + () -> + StatusHolder.init(scope) + .definePrototypeProperty( + cx, "status", null, null, DONTENUM)); + assertTrue( + error.toString() + .contains("at least one of {getter, setter} is required")); + return null; + }); + } + + @Test + public void testCanUpdateValueUsingSetter() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + StatusHolder.init(scope) + .definePrototypeProperty( + cx, + "status", + (thisObj) -> self(thisObj).getStatus(), + (thisObj, value) -> self(thisObj).setStatus(value), + DONTENUM); + + Object getterResult = + cx.evaluateString( + scope, + "s = new StatusHolder('InProgress'); s.status", + "source", + 1, + null); + assertEquals("InProgress", getterResult); + + Object setResult = + cx.evaluateString(scope, "s.status = 'DONE';", "source", 1, null); + + Object newStatus = cx.evaluateString(scope, "s.status", "source", 1, null); + assertEquals("NewStatus: DONE", newStatus); + return null; + }); + } + + @Test + public void testOnlyGetterCanBeAccessed() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + StatusHolder.init(scope) + .definePrototypeProperty( + cx, "status", (thisObj) -> self(thisObj).getStatus(), DONTENUM); + + Object getterResult = + cx.evaluateString( + scope, "new StatusHolder('OK').status", "source", 1, null); + assertEquals("OK", getterResult); + + Object hiddenFieldResult = + cx.evaluateString( + scope, + "new StatusHolder('OK').hiddenStatus", + "source", + 1, + null); + assertEquals( + "fields not explicitly defined as properties should return undefined", + Undefined.instance, + hiddenFieldResult); + return null; + }); + } + + @Test + public void testWhenNoSetterDefined_InStrictMode_WillThrowException() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + StatusHolder.init(scope) + .definePrototypeProperty( + cx, "status", (thisObj) -> self(thisObj).getStatus(), DONTENUM); + Object getterResult = + cx.evaluateString( + scope, + "s = new StatusHolder('Constant'); s.status", + "source", + 1, + null); + assertEquals("Constant", getterResult); + + var error = + assertThrows( + EcmaError.class, + () -> + cx.evaluateString( + scope, + "\"use strict\"; s.status = 'DONE'; s.status", + "source", + 1, + null)); + String expectedError = + ScriptRuntime.getMessageById( + "msg.set.prop.no.setter", "[StatusHolder].status", "DONE"); + assertTrue(error.toString().contains(expectedError)); + return null; + }); + } + + @Test + public void testWhenNoSetterDefined_InNormalMode_NoErrorButValueIsNotChanged() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + StatusHolder.init(scope) + .definePrototypeProperty( + cx, "status", (thisObj) -> self(thisObj).getStatus(), DONTENUM); + + Object getterResult = + cx.evaluateString( + scope, + "s = new StatusHolder('Constant'); s.status", + "source", + 1, + null); + assertEquals("Constant", getterResult); + + Object setResult = + cx.evaluateString( + scope, "s.status = 'DONE'; s.status", "source", 1, null); + assertEquals("status won't be changed", "Constant", setResult); + + Object shObj = cx.evaluateString(scope, "s", "source", 1, null); + var statusHolder = (StatusHolder) shObj; + assertEquals("Constant", statusHolder.getStatus()); + return null; + }); + } + + @Test + public void testSetterOnly_WillModifyUnderlyingValue() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + StatusHolder.init(scope) + .definePrototypeProperty( + cx, + "status", + null, + (thisObj, value) -> self(thisObj).setStatus(value), + DONTENUM); + cx.evaluateString(scope, "s = new StatusHolder('Constant')", "source", 1, null); + + cx.evaluateString(scope, "s.status = 'DONE'; s.status", "source", 1, null); + + Object newStatus = cx.evaluateString(scope, "s.status", "source", 1, null); + assertEquals(null, newStatus); + Object shObj = cx.evaluateString(scope, "s", "source", 1, null); + var statusHolder = (StatusHolder) shObj; + assertEquals("NewStatus: DONE", statusHolder.getStatus()); + return null; + }); + } + + // using getOwnPropertyDescriptor to access property + + @Test + public void testGetterUsing_getOwnPropertyDescriptor() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + StatusHolder.init(scope) + .definePrototypeProperty( + cx, "status", (thisObj) -> self(thisObj).getStatus(), DONTENUM); + + Object result = + cx.evaluateString( + scope, + "s = new StatusHolder('InProgress');" + + "f = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(s), 'status');" + + "f.get.call(s)", + "source", + 1, + null); + assertEquals("InProgress", result); + return null; + }); + } + + @Test + public void testSetterOnlyUsing_getOwnPropertyDescriptor() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + StatusHolder.init(scope) + .definePrototypeProperty( + cx, + "status", + null, + (thisObj, value) -> self(thisObj).setStatus(value), + DONTENUM); + + Object shObj = + cx.evaluateString( + scope, + "s = new StatusHolder('InProgress');" + + "f = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(s), 'status');" + + "f.set.call(s, 'DONE');" + + "s", + "source", + 1, + null); + var statusHolder = (StatusHolder) shObj; + assertEquals("NewStatus: DONE", statusHolder.getStatus()); + return null; + }); + } + + @Test + public void testSetValueUsing_getOwnPropertyDescriptor() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + StatusHolder.init(scope) + .definePrototypeProperty( + cx, + "status", + (thisObj) -> self(thisObj).getStatus(), + (thisObj, value) -> self(thisObj).setStatus(value), + DONTENUM); + + Object result = + cx.evaluateString( + scope, + "s = new StatusHolder('InProgress');" + + "f = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(s), 'status');" + + "f.set.call(s, 'DONE');" + + "s.status", + "source", + 1, + null); + assertEquals("Status with prefix", "NewStatus: DONE", result); + return null; + }); + } + + @Test + public void testSetterOnlyUsing_getOwnPropertyDescriptor_ErrorOnGet() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + StatusHolder.init(scope) + .definePrototypeProperty( + cx, + "status", + null, + (thisObj, value) -> self(thisObj).setStatus(value), + DONTENUM); + + var error = + assertThrows( + EcmaError.class, + () -> + cx.evaluateString( + scope, + "var s = new StatusHolder('InProgress');" + + "var f = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(s), 'status');" + + "f.get.call(s)", + "source", + 1, + null)); + assertTrue( + error.toString().contains("Cannot call method \"call\" of undefined")); + return null; + }); + } + + @Test + public void testRedefineExistingProperty_ChangingConfigurableAttr_ShouldFailValidation() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + var sh = new StatusHolder("PENDING"); + ScriptableObject existingDesc = (ScriptableObject) cx.newObject(scope); + + // + existingDesc.defineProperty("configurable", false, ScriptableObject.EMPTY); + + sh.defineOwnProperty(cx, "status", existingDesc); + + var error = + assertThrows( + EcmaError.class, + () -> + sh.defineProperty( + cx, + "status", + (thisObj) -> self(thisObj).getStatus(), + (thisObj, value) -> + self(thisObj).setStatus(value), + DONTENUM)); + assertTrue( + error.toString() + .contains( + ScriptRuntime.getMessageById( + "msg.change.configurable.false.to.true", + "status"))); + return null; + }); + } + + @Test + public void + testRedefineExistingProperty_ModifyingNotConfigurableProperty_ShouldFailValidation() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + var sh = new StatusHolder("PENDING"); + ScriptableObject existingDesc = (ScriptableObject) cx.newObject(scope); + + // + existingDesc.defineProperty("configurable", false, ScriptableObject.EMPTY); + existingDesc.defineProperty("enumerable", true, ScriptableObject.EMPTY); + + sh.defineOwnProperty(cx, "status", existingDesc); + + var error = + assertThrows( + EcmaError.class, + () -> + sh.defineProperty( + cx, + "status", + (thisObj) -> self(thisObj).getStatus(), + (thisObj, value) -> + self(thisObj).setStatus(value), + // making new property configurable: false and + // enumerable: + // false + DONTENUM | PERMANENT)); + assertTrue( + error.toString() + .contains( + ScriptRuntime.getMessageById( + "msg.change.enumerable.with.configurable.false", + "status"))); + return null; + }); + } + + @Test + public void testSetterOnlyUsing_getOwnPropertyDescriptor_InStrictMode_ErrorOnGet() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + StatusHolder.init(scope) + .definePrototypeProperty( + cx, + "status", + null, + (thisObj, value) -> self(thisObj).setStatus(value), + DONTENUM); + + var error = + assertThrows( + EcmaError.class, + () -> + cx.evaluateString( + scope, + "\"use strict\";" + + "var s = new StatusHolder('InProgress');" + + "var f = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(s), 'status');" + + "f.get.call(s)", + "source", + 1, + null)); + assertTrue( + error.toString().contains("Cannot call method \"call\" of undefined")); + return null; + }); + } + + @Test + public void testGetterOnlyUsing_getOwnPropertyDescriptor_ErrorOnSet() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + StatusHolder.init(scope) + .definePrototypeProperty( + cx, "status", (thisObj) -> self(thisObj).getStatus(), DONTENUM); + + var error = + assertThrows( + EcmaError.class, + () -> + cx.evaluateString( + scope, + "var s = new StatusHolder('InProgress');" + + "var f = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(s), 'status');" + + "f.set.call(s, 'DONE');" + + "s.status", + "source", + 1, + null)); + assertTrue( + error.toString().contains("Cannot call method \"call\" of undefined")); + return null; + }); + } + + @Test + public void testGetterOnlyUsing_getOwnPropertyDescriptor_InStrictMode_ErrorOnSet() { + Utils.runWithAllOptimizationLevels( + cx -> { + Scriptable scope = cx.initStandardObjects(); + StatusHolder.init(scope) + .definePrototypeProperty( + cx, "status", (thisObj) -> self(thisObj).getStatus(), DONTENUM); + + var error = + assertThrows( + EcmaError.class, + () -> + cx.evaluateString( + scope, + "\"use strict\";" + + "var s = new StatusHolder('InProgress');" + + "var f = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(s), 'status');" + + "f.set.call(s, 'DONE');" + + "s.status", + "source", + 1, + null)); + assertTrue( + error.toString().contains("Cannot call method \"call\" of undefined")); + return null; + }); + } + + static class StatusHolder extends ScriptableObject { + private String status; + private final String hiddenStatus; + + static LambdaConstructor init(Scriptable scope) { + LambdaConstructor constructor = + new LambdaConstructor( + scope, + "StatusHolder", + 1, + LambdaConstructor.CONSTRUCTOR_NEW, + (cx, scope1, args) -> new StatusHolder((String) args[0])); + + ScriptableObject.defineProperty(scope, "StatusHolder", constructor, DONTENUM); + return constructor; + } + + static StatusHolder self(Scriptable thisObj) { + return LambdaConstructor.convertThisObject(thisObj, StatusHolder.class); + } + + StatusHolder(String status) { + this.status = status; + this.hiddenStatus = "NotQuiteReady"; + } + + public String getStatus() { + return status; + } + + @Override + public String getClassName() { + return "StatusHolder"; + } + + public void setStatus(Object value) { + this.status = "NewStatus: " + (String) value; + } + } +}