qooxdoo / qooxdoo

qooxdoo - Universal JavaScript Framework
http://qooxdoo.org
Other
764 stars 260 forks source link

Properties rewrite proposal #10410

Open johnspackman opened 2 years ago

johnspackman commented 2 years ago

This is a proposal for a complete rewrite of the properties mechanism in Qooxdoo classes, it is 100% BC and adds a number of really useful features; a big motivation for this complete rewrite is that the previous system relied on code generation, which was great for IE6 but in modern JS engines this increases memory usage and circumvents JIT compiler technology. Worse of all, the code generation code has grown to be virtually unmaintainable.

The second big motivation is to improve the ease of use, both when defining properties (simpler specification boilerplate) and using them (native properties), and to add better type checking and control over storage and other semantics.

Finally, this is to allow weak references to be built into Qooxdoo in a way that is transparent and can be managed automatically with garbage collection.

New Property Features:

References are included in this PR because property support for References is integral to making References transparent and useful. Reference features are:

Native properties

Add native property getters/setters so that it is possible to use myObject.someValue instead of myObject.getSomeValue() / myObject.setSomeValue(…).

Compiler Changes

This should also work for arrays backed by qx.data.Array, so that myObject.getMyArray().getItem(0) becomes myObject.myArray[0] but this requires compiler changes to translate all array references into a function, eg qx.data.Array.get(myObject.myArray, 0) which tests for the type of myObject.myArray

Eliminate Psuedo Properties

There are lots of places where objects have to define something that looks like a property, but does not list it in the properties section. The most common example is usually because the property stores an instance of qx.data.Array, and when you call object.setArrayProperty() with a new value, the widget only wants to change the contents of the array and does not want to change the actual instance of the array.

For example, see qx.ui.form.MModelSelection and the modelSelection property.

What MModelSelection.js has to do is define a setModelSelection, getModelSelection, resetModelSelection and an event called "changeModelSelection" - it must define all four because otherwise the binding will fail. IMHO this is poor because it's not clear from MModelSelection.js whats going on, plus it's reproducing all the existing property code, and it makes it impossible for the framework to add features without declaring that class as broken.

There are two ways to solve this in this plan, one is to override the storage, maybe something like this:

properties: {
  modelSelection: {
    init: () => new qx.data.Array(),
    get: () => this.$$user_modelSelection,
    set: value => {
      if (!this.$$user_modelSelection)
        this.$$user_modelSelection = value;
      else
        this.$$user_modelSelection.replace(value||[]);
    }
  }
}

or because arrays are a common usage, to bake it into the property mechanism, eg this version:

properties: {
  modelSelection: {
    init: () => new qx.data.Array(),
    immutable: "replace"
  }
}

This would be properly defined, so that immutable: "replace" only works on objects which implement some interface eg qx.data.IReplacable, and qx.data.Array would implement that interface.

When the developer tries to set the value of modelSelection, the items are copied into the existing modelSelection array.

You can get the modelSelection property and change it directly, and that's part of the reason for this kind of pattern. If you are reacting to changes in the modelSelection array, you would normally have to listen for "changeModelSelection" to know when the property value changes, and then add/remove listeners for the "change" event for old and new values, and sync the array up to whatever you're listening to it for.

It's basically a boat load of extra hard work (and it can get exponential), and so by simply saying "ok this property value will never change, just it's contents" you make life massively easier, without sacrificing compatibility or the dynamics of binding.

Because properties are not always defined in the properties section, any code (eg the binding code) cannot rely on reflection to get properties, it has to do it by sniffing.

This impacts storage and immutability

Storage

Properties must be able to override the storage mechanism, either by providing your own get, set etc or by specifying that it is held by reference (and to be able to choose the reference implementation).

The minimum required would be get and set, all others (eg reset etc) would have default implementations

Immutable Values

Support properties where the value stored is locked; for scalar values, this could mean that the property is read only and attempts to change it are ignored or an exception is raised, for objects like qx.data.Array, this would mean that the instance never changes but the contents of the array are changed:

arrayProp: {
  init: () => new qx.data.Array(),
  immutable: "replace"
}

Where immutable is one of [ null, “replace”, “readOnly” ]

var array = myObject.getArrayProp(); // instanceof qx.data.Array
var tmp = new qx.data.Array();
tmp.add(1);
tmp.add("two");
myObject.setArrayProp(tmp);
qx.core.Assert.assertTrue(array === myObject.getArrayProp());
qx.core.Assert.assertTrue(tmp !== myObject.getArrayProp());
qx.core.Assert.assertTrue(tmp.equals(array));
myObject.setArrayProp(null);
qx.core.Assert.assertTrue(array === myObject.getArrayProp());
qx.core.Assert.assertTrue(myObject.getArrayProp().getLength() === 0);

Detecting Mutation

Check whether a property is being changed eg

if (myObject.isPropertyMutating(“myProp")) { 
  await myObject.resolveMutation(“myProp"); 
}

(Or fire “beforeChangeXxx” and “afterChangeXxx” ??)

First-Class Implementation

Each property should be backed by a first class object, which has .set(object, value), .get(object), .check(value) etc methods. It should be simple to obtain these instances, eg myObject.constructor.getProperty(“someValue”).get() is the same as myObject.getSomeValue().

The qx.Class definition will detect existing pseudo-properties and warn that it is deprecated to use pseudo-properties but wrap the methods with first class property objects in the mean time, so that property reflection is accurate.

A separate project would be to migrate the binding and other classes over to this mechanism.

Redefining

Currently there are only limited options for redefining a property, this will be expanded - specifically, type checks will be able to be further constrained in derived classes

Type Checking

Type checking will become more advanced expression, the same kind of expression that we use for JSDOC - it will include support for parameterised types (eg "qx.data.Array<MyClass>"), logical OR “|”, and indicate nullability with a trailing ?

Checks will be implemented as a separate, first-class object because this will allow the check code to be used. The checks will follow JSDoc so that, in the future, the compiler could be augmented to copy the JSDoc into type-checking code inserted into the method for parameters and return types.

Deprecate nullable

Whether a property is nullable or not becomes part of the type checking - initially we will support both, and will provide a tool (similar to qx es6ify) to migrate nullable into a check. It will only throw an error if there is a conflict, eg check: "Boolean?", nullable: false would not be allowed

Private and Protected Properties

Protected and private properties will be supported, with mangling for private properties.

I don't know what happens to properties with "_" and "__" prefixes at the moment - but if it turns out that you end up with methods like get_myProperty, then this will be deprecated in exchange for _getMyProperty etc.

Fast property definition

Many properties are very similar in that they have a check, an event, an optional apply method, and the event name at least follows a strict rule of "changePropertyName" - we know that rule is adhered to because the binding requires it. With nullability included in the type check expression, we can provide an optional "fast" definition of just the type definition:

properties: {
  name: "String?",
  address: "String?",
  children: {
    init: () => new qx.data.Array(),
    check: "qx.data.Array<mypkg.Person>",
    immutable: "replace"
  }
},

members: {
  _applyName(value, oldValue) {
    /* ... snip... */
  }
}

would be the same as:

properties: {
  name: {
    init: null,
    check: "String?",
    event: "changeName",
    apply: "_applyName"
  },
  address: {
    init: null,
    check: "String?",
    event: "changeAddress"
  },
  children: {
    init: () => new qx.data.Array(),
    check: "qx.data.Array<mypkg.Person>",
    immutable: "replace"
  }
},

members: {
  _applyName(value, oldValue) {
    /* ... snip... */
  }
}

In this example, note that the event name is assumed and the apply method is auto-detected.

Event Names

Every property will have an event name, unless you specify event: null. You can manually provide an event name - although it will be a warning to use an event name which is not in the form "changePropertyName"

Asynchronous properties

The tough issue with using asynchronous properties is that you have to await for every stage, and there isn't any clear indication that you need to - myObject.myArrayProperty[2].myOtherProperty looks like a normal expression (with native properties), but what if myArrayProperty was defined as asynchronous, and on demand?

If myArrayProperty was already known, then the getter can return immediately (ie synchronously) but that makes for unpredictable behaviour unless you can guarantee that you fetch the value (with await myObject.getMyArrayPropertyAsync()) earlier in the code.

Another approach would be to use bindings, except that although bindings do not currently support async properties at all, this is something that could change.

There is a possible work around where a call to an asynchronous function can be made to be synchronous, outlined here in web workers: https://www.smashingmagazine.com/2022/04/partytown-eliminates-website-bloat-third-party-apps/

Init

init property to accept a function that is called to create the init value; if init is null, then assume nullable:true (or a nullable type check) unless explicitly assigned

Add the ability to check for an uninitialised properties without triggering an exception because the property is “not yet ready”

Other

apply to support functions instead of strings

References

All Qooxdoo objects stored in properties will be tracked via a Reference class, which can be a HardReference, WeakReference, or OnDemandReference.

WeakReferences will be backed by WeakRef which is now in all browsers and nodejs since v14

On Demand

OnDemandReferences have a URI that can be used to load an object on demand, and internally use a Hard/Soft/Weak Reference to store the value in the mean time.

This means that there will be some kind of LifecycleManager for different classes as objects come and go

Examples

These are not hard and fast, just some ideas:

myProperty: {
    get: (obj) => obj.$$user_myProperty,
    set: (obj, value) => obj.$$user_myProperty = value
}

myArray: {    
    check: “qx.data.Array<qx.core.Object>”,
    init: () => new qx.data.Array()
}
johnspackman commented 1 year ago

I've been testing your branch with my code; I've found two issues so far, the second is a blocker

(1) https://github.com/qooxdoo/qooxdoo/blob/new-class-property-system/source/class/qx/Class.js#L804

            delete properties[property].refine;
            properties[property] =
              Object.assign(
                {}, allProperties[property] || {}, properties[property]);

            // We only get here if `refine : true` was in the configuration.
            // That doesn't say whether there was actually a superclass
            // property for it to refine. It's not an error to refine a
            // non-existing property. Keep track of whether we actually
            // refined a property.
            refined = property in allProperties;

Because the property's .refine has been deleted, that's information is lost to reflection; I worked around this by changing the last line in the snippet to:

            properties[property].refined = refined = property in allProperties;

(2) Async properties now require a get and apply: https://github.com/qooxdoo/qooxdoo/blob/new-class-property-system/source/class/qx/Class.js#L2643-L2648