Selaa lähdekoodia

Fixed: Model binding fails with as() when using custom attribute names.

Aleksander Nowodzinski 10 vuotta sitten
vanhempi
commit
3e3bd7a8cf
2 muutettua tiedostoa jossa 398 lisäystä ja 161 poistoa
  1. 234 141
      packages/ckeditor5-utils/src/model.js
  2. 164 20
      packages/ckeditor5-utils/tests/model.js

+ 234 - 141
packages/ckeditor5-utils/src/model.js

@@ -35,22 +35,83 @@ export default class Model {
 		this._attributes = {};
 
 		/**
-		 * Map containing bindings of this model to external models.
+		 * Map containing bindings to external models. It shares the binding objects
+		 * (`{ model: A, attr: 'a', to: ... }`) with {@link #_boundAttributes} and
+		 * it is used to observe external models to update own attributes accordingly.
 		 * See {@link #bind}.
 		 *
+		 *		A.bind( 'a', 'b', 'c' ).to( B, 'x', 'y', 'x' );
+		 *		console.log( A._boundModels );
+		 *
+		 *			Map( {
+		 *				B: {
+		 *					x: Set( [
+		 *						{ model: A, attr: 'a', to: [ [ B, 'x' ] ] },
+		 *						{ model: A, attr: 'c', to: [ [ B, 'x' ] ] }
+		 *					] ),
+		 *					y: Set( [
+		 *						{ model: A, attr: 'b', to: [ [ B, 'y' ] ] },
+		 *					] )
+		 *				}
+		 *			} )
+		 *
+		 *		A.bind( 'd' ).to( B, 'z' ).to( C, 'w' ).as( callback );
+		 *		console.log( A._boundModels );
+		 *
+		 *			Map( {
+		 *				B: {
+		 *					x: Set( [
+		 *						{ model: A, attr: 'a', to: [ [ B, 'x' ] ] },
+		 *						{ model: A, attr: 'c', to: [ [ B, 'x' ] ] }
+		 *					] ),
+		 *					y: Set( [
+		 *						{ model: A, attr: 'b', to: [ [ B, 'y' ] ] },
+		 *					] ),
+		 *					z: Set( [
+		 *						{ model: A, attr: 'd', to: [ [ B, 'z' ], [ C, 'w' ] ], callback: callback }
+		 *					] )
+		 *				},
+		 *				C: {
+		 *					w: Set( [
+		 *						{ model: A, attr: 'd', to: [ [ B, 'z' ], [ C, 'w' ] ], callback: callback }
+		 *					] )
+		 *				}
+		 *			} )
+		 *
 		 * @private
 		 * @property {Map}
 		 */
-		this._boundTo = new Map();
+		this._boundModels = new Map();
 
 		/**
-		 * Object that stores which attributes of this model are bound.
+		 * Object that stores which attributes of this model are bound and how. It shares
+		 * the binding objects (`{ model: A, attr: 'a', to: ... }`) with {@link #_boundModels}.
+		 * This data structure is a reverse of {@link #_boundModels} and it is helpful for {@link #unbind}.
 		 * See {@link #bind}.
 		 *
+		 *		A.bind( 'a', 'b', 'c' ).to( B, 'x', 'y', 'x' );
+		 *		console.log( A._boundAttributes );
+		 *
+		 *			{
+		 *				a: { model: A, attr: 'a', to: [ [ B, 'x' ] ] },
+		 *				b: { model: A, attr: 'b', to: [ [ B, 'y' ] ] },
+		 *				c: { model: A, attr: 'c', to: [ [ B, 'x' ] ] }
+		 *			}
+		 *
+		 *		A.bind( 'd' ).to( B, 'z' ).to( C, 'w' ).as( callback );
+		 *		console.log( A._boundAttributes );
+		 *
+		 *			{
+		 *				a: { model: A, attr: 'a', to: [ [ B, 'x' ] ] },
+		 *				b: { model: A, attr: 'b', to: [ [ B, 'y' ] ] },
+		 *				c: { model: A, attr: 'c', to: [ [ B, 'x' ] ] },
+		 *				d: { model: A, attr: 'd', to: [ [ B, 'z' ], [ C, 'w' ] ], callback: callback }
+		 *			}
+		 *
 		 * @private
 		 * @property {Object}
 		 */
-		this._bound = {};
+		this._boundAttributes = {};
 
 		// Extend this instance with the additional (out of state) properties.
 		if ( properties ) {
@@ -139,7 +200,7 @@ export default class Model {
 	 *		A.bind( 'a' ).to( B );
 	 *		A.bind( 'a' ).to( B, 'b' );
 	 *		A.bind( 'a', 'b' ).to( B, 'c', 'd' );
-	 *		A.bind( 'a' ).to( B, 'b' ).to( C, 'd' ).as( ( Bb, Cd ) => Bb + Cd );
+	 *		A.bind( 'a' ).to( B, 'b' ).to( C, 'd' ).as( ( b, d ) => b + d );
 	 *
 	 * @param {String...} bindAttrs Model attributes use that will be bound to another model(s).
 	 * @returns {BindChain}
@@ -164,7 +225,7 @@ export default class Model {
 		}
 
 		bindAttrs.forEach( attrName => {
-			if ( attrName in this._bound ) {
+			if ( attrName in this._boundAttributes ) {
 				/**
 				 * Cannot bind the same attribute more that once.
 				 *
@@ -172,29 +233,42 @@ export default class Model {
 				 */
 				throw new CKEditorError( 'model-bind-rebind: Cannot bind the same attribute more that once.' );
 			}
+		} );
 
-			this._bound[ attrName ] = true;
+		const bindings = {};
+
+		bindAttrs.forEach( a => {
+			this._boundAttributes[ a ] = bindings[ a ] = { model: this, attr: a, to: [] };
 		} );
 
 		/**
 		 * @typedef BindChain
 		 * @type Object
-		 * @property {Model} _bindModel The model which initializes the binding.
-		 * @property {Array} _bindAttrs Array of `_bindModel` attributes to be bound.
-		 * @property {Array} _boundTo Array of `to()` model–attributes (`{ model: toModel, attrs: ...toAttrs }`).
-		 * @property {Object} _current The arguments of the last `to( toModel, ...toAttrs )` call, also
-		 * the last item of `_boundTo`.
 		 * @property {Function} to See {@link #_bindTo}.
 		 * @property {Function} as See {@link #_bindAs} (available after `to()` called in chain).
+		 * @property {Model} _model The model which initializes the binding.
+		 * @property {Array} _bindAttrs Array of `_model` attributes to be bound.
+		 * @property {Array} _to Array of `to()` model–attributes (`{ model: toModel, attrs: ...toAttrs }`).
+		 * @property {Object} _bindings Stores bindings to be kept in {@link #_boundAttributes}/{@link #_boundModels}
+		 * initiated in this binding chain.
+		 * @property {Function} _lastToModel A helper, retrieves `model` from last of `_to`.
+		 * @property {Function} _lastToAttrs A helper, retrieves `attrs` from last of `_to`.
 		 */
 		return {
-			_bindModel: this,
+			to: this._bindTo,
+
+			_model: this,
 			_bindAttrs: bindAttrs,
-			_boundTo: [],
-			get _current() {
-				return this._boundTo[ this._boundTo.length - 1 ];
+			_to: [],
+			_bindings: bindings,
+
+			get _lastToModel() {
+				return this._to[ this._to.length - 1 ].model;
 			},
-			to: this._bindTo
+
+			get _lastToAttrs() {
+				return this._to[ this._to.length - 1 ].attrs;
+			}
 		};
 	}
 
@@ -219,36 +293,37 @@ export default class Model {
 			}
 
 			unbindAttrs.forEach( attrName => {
-				for ( let to of this._boundTo ) {
-					// TODO, ES6 destructuring.
-					const boundModel = to[ 0 ];
-					const bindings = to[ 1 ];
-
-					for ( let boundAttrName in bindings ) {
-						if ( bindings[ boundAttrName ].has( attrName ) ) {
-							bindings[ boundAttrName ].delete( attrName );
-						}
-
-						if ( !bindings[ boundAttrName ].size ) {
-							delete bindings[ boundAttrName ];
-						}
-
-						if ( !Object.keys( bindings ).length ) {
-							this._boundTo.delete( boundModel );
-							this.stopListening( boundModel, 'change' );
-						}
+				const binding = this._boundAttributes[ attrName ];
+				let toModel, toAttr, toAttrs, toAttrBindings;
+
+				binding.to.forEach( to => {
+					// TODO: Destructuring.
+					toModel = to[ 0 ];
+					toAttr = to[ 1 ];
+					toAttrs = this._boundModels.get( toModel );
+					toAttrBindings = toAttrs[ toAttr ];
+
+					toAttrBindings.delete( binding );
+
+					if ( !toAttrBindings.size ) {
+						delete toAttrs[ toAttr ];
 					}
-				}
 
-				delete this._bound[ attrName ];
+					if ( !Object.keys( toAttrs ).length ) {
+						this._boundModels.delete( toModel );
+						this.stopListening( toModel, 'change' );
+					}
+				} );
+
+				delete this._boundAttributes[ attrName ];
 			} );
 		} else {
-			this._boundTo.forEach( ( bindings, boundModel ) => {
+			this._boundModels.forEach( ( bindings, boundModel ) => {
 				this.stopListening( boundModel, 'change' );
-				this._boundTo.delete( boundModel );
 			} );
 
-			this._bound = {};
+			this._boundModels.clear();
+			this._boundAttributes = {};
 		}
 	}
 
@@ -281,7 +356,7 @@ export default class Model {
 
 		// Eliminate A.bind( 'x' ).to( B, 'y', 'z' )
 		// Eliminate A.bind( 'x', 'y' ).to( B, 'z' )
-		if ( toAttrs.length && toAttrs.length !== this._bindAttrs.length ) {
+		if ( toAttrs.length && toAttrs.length !== Object.keys( this._bindings ).length ) {
 			/**
 			 * The number of attributes must match.
 			 *
@@ -303,7 +378,7 @@ export default class Model {
 
 		// Eliminate A.bind( 'x', 'y' ).to( B ).to( C ) when no trailing .as().
 		// Eliminate A.bind( 'x', 'y' ).to( B, 'x', 'y' ).to( C, 'x', 'y' ).
-		if ( this._boundTo.length && ( toAttrs.length > 1 || this._bindAttrs.length > 1 ) ) {
+		if ( this._to.length && ( toAttrs.length > 1 || this._bindAttrs.length > 1 ) ) {
 			/**
 			 * Chaining only allowed for a single attribute.
 			 *
@@ -318,12 +393,12 @@ export default class Model {
 		}
 
 		// Extend current chain with the new binding information.
-		this._boundTo.push( { model: toModel, attrs: toAttrs } );
+		this._to.push( { model: toModel, attrs: toAttrs } );
 
-		setupBinding( this );
+		setupBindToBinding( this );
 
 		if ( !this.as ) {
-			this.as = this._bindModel._bindAs;
+			this.as = this._model._bindAs;
 		}
 
 		return this;
@@ -345,9 +420,9 @@ export default class Model {
 			throw new CKEditorError( 'model-bind-as-wrong-callback: Callback must be a Function.' );
 		}
 
-		this._callback = callback;
+		this._model._boundAttributes[ this._bindAttrs[ 0 ] ].callback = this._callback = callback;
 
-		updateModelAttrs( this, this._bindAttrs[ 0 ] );
+		updateModelAttrs( this._model, this._lastToModel, this._lastToAttrs[ 0 ] );
 	}
 }
 
@@ -375,133 +450,151 @@ function isStringArray( arr ) {
 }
 
 /**
- * Returns all bindings of the `chain._bindModel` to `chain._current.model`
- * set by {@link #updateModelBindingsToCurrent}.
- *
- *		// Given that A == _bindModel and B == _current.model
- *		A.bind( 'a', 'b', 'c' ).to( B, 'x', 'y', 'x' );
- *
- *		// The following object is returned
- *		{ x: [ 'a', 'c' ], y: [ 'b' ] }
- *
+ * Synchronizes `chain._model._boundAttributes` and `chain._model._boundModels`
+ * with `chain`.
  *
  * @private
- * @param {BindChain} chain The chain initialized by {@link Model#bind}.
- * @returns {Object}
+ * @param {BindChain} chain The binding initialized by {@link Model#bind}.
  */
-function getModelBindingsToCurrent( chain ) {
-	return chain._bindModel._boundTo.get( chain._current.model );
-}
+function updateBoundAttributesAndModels( chain ) {
+	const lastToModel = chain._lastToModel;
+	const lastToAttrs = chain._lastToAttrs;
+
+	let lastBoundAttr, bindingsToLastModel, bindings, binding;
+
+	// Assuming the following binding being created
+	//
+	// 		A.bind( 'a', 'b', 'c' ).to( B, 'x', 'y' );
+	//
+	// the following bindings were initialized in `Model#bind` in `chain._bindings`:
+	//
+	// 		{
+	// 			a: { model: A, attr: 'a', to: [] },
+	// 			b: { model: A, attr: 'b', to: [] },
+	// 		}
+	//
+	// Iterate over all bindings in this chain and fill their `to` properties with
+	// the latest to( ... ) call arguments.
+	for ( let attrName in chain._bindings ) {
+		binding = chain._bindings[ attrName ];
+
+		// Update `to` property, so the bindings are:
+		//
+		// 		a: { model: A, attr: 'a', to: [ [ B, 'x' ] ] },
+		//
+		//	and
+		//
+		// 		b: { model: A, attr: 'b', to: [ [ B, 'y' ] ] },
+		//
+		// But since `chain._bindings` and `chain._model._boundAttributes` share
+		// the instances of the bindings, a model is also updated.
+		lastBoundAttr = lastToAttrs[ chain._bindAttrs.indexOf( attrName ) ];
+		binding.to.push( [ lastToModel, lastBoundAttr ] );
 
-/**
- * Updates `chain._bindModel._boundTo` with a binding for `chain._current`.
- * The binding can be then retrieved by {@link #getModelBindingsToCurrent}.
- *
- * @private
- * @param {BindChain} chain The chain initialized by {@link Model#bind}.
- * @returns {Object}
- */
-function updateModelBindingsToCurrent( chain ) {
-	const currentBindings = getModelBindingsToCurrent( chain );
-	const bindings = currentBindings || {};
+		// Update the structure of `chain._model._boundModels` with updated
+		// binding, so:
+		//
+		// 		chain._model._boundModels == Map( {
+		// 			B: {
+		// 				x: Set( [
+		// 					{ model: A, attr: 'a', to: [ [ B, 'x' ] ] }
+		// 				] ),
+		// 				y: Set( [
+		// 					{ model: A, attr: 'b', to: [ [ B, 'y' ] ] },
+		// 				] )
+		//			}
+		// 		} )
+		//
+		bindingsToLastModel = chain._model._boundModels.get( lastToModel );
+		bindings = bindingsToLastModel || {};
 
-	chain._current.attrs.forEach( ( attrName, index ) => {
-		( bindings[ attrName ] || ( bindings[ attrName ] = new Set() ) )
-			.add( chain._bindAttrs[ index ] );
-	} );
+		if ( !bindings[ lastBoundAttr ] ) {
+			bindings[ lastBoundAttr ] = new Set();
+		}
 
-	if ( !currentBindings ) {
-		chain._bindModel._boundTo.set( chain._current.model, bindings );
-	}
-}
+		// Pass the binding to a corresponding Set in `chain._model._boundModels`.
+		bindings[ lastBoundAttr ].add( binding );
 
-/**
- * Updates the model attribute with given value. If an attribute does not exist,
- * it is created on the fly.
- *
- * @private
- * @param {Model} model The model which attribute is updated.
- * @param {String} attrName The name of the attribute.
- * @param {*} value The value of the attribute.
- */
-function updateModelAttr( model, attrName, value ) {
-	if ( model[ attrName ] ) {
-		model[ attrName ] = value;
-	} else {
-		model.set( attrName, value );
+		if ( !bindingsToLastModel ) {
+			chain._model._boundModels.set( lastToModel, bindings );
+		}
 	}
 }
 
 /**
- * Updates all bound attributes of `chain._bindModel` with the `value` of
- * `attrName` of `chain._current` model.
+ * Updates all bound attributes of `updateModel` with the `value` of `attrName`
+ * of `withModel` model.
  *
- *		// Given that A == _bindModel and B == _current.model
+ *		// Given that A == updateModel and B == withModel and B.x has just changed.
  *		A.bind( 'a', 'b', 'c' ).to( B, 'x', 'y', 'x' );
  *
  *		// The following is updated
  *		A.a = A.c = B.x;
- *		A.b = B.y;
  *
  * @private
- * @param {BindChain} chain The chain initialized by {@link Model#bind}.
- * @param {String} attrName One of the attributes of `chain._current`.
+ * @param {Model} updateModel The model to be updated.
+ * @param {Model} withModel The model to be be used as a source.
+ * @param {String} attrName One of the attributes of `withModel`.
  * @param {*} value The value of the attribute.
  */
-function updateModelAttrs( chain, attrName, value ) {
-	const boundAttrs = getModelBindingsToCurrent( chain )[ attrName ];
-
-	if ( !boundAttrs ) {
-		return;
-	} else if ( chain._callback ) {
-		// MODEL.bind( 'a' ).to( TOMODEL1, 'b1' )[ .to( TOMODELn, 'bn' ) ].as( callback )
-		//  \-> Collect specific attribute value in the boundTo.model (TOMODELn.bn).
-		//
-		// MODEL.bind( 'a' ).to( TOMODEL1 )[ .to( TOMODELn ) ].as( callback )
-		//  \-> Use model attribute name to collect boundTo attribute value (TOMODELn.a).
-		const values = chain._boundTo.map( boundTo => boundTo.model[ boundTo.attrs[ 0 ] ] );
-
-		// Pass collected attribute values to the callback function.
-		// Whatever is returned it becomes the value of the model's attribute.
-		updateModelAttr(
-			chain._bindModel,
-			chain._bindAttrs[ 0 ],
-			chain._callback.apply( chain._bindModel, values )
-		);
-	} else {
-		// MODEL.bind( 'a' ).to( TOMODEL1 )[ .to( TOMODELn ) ];
-		//  \-> If multiple .to() models but **no** .as( callback ), then the binding is invalid.
-		if ( !chain._callback && chain._boundTo.length > 1 ) {
-			value = undefined;
-		}
+function updateModelAttrs( updateModel, withModel, attrName, value ) {
+	const bindings = updateModel._boundModels.get( withModel )[ attrName ];
+	let attrValue;
+
+	if ( bindings ) {
+		bindings.forEach( binding => {
+			attrValue = value;
+
+			// A.bind( 'a' ).to( B, 'b' ).to( C, 'c' ).as( callback );
+			//  \-> Collect B.b and C.c and pass the values to callback to set A.a.
+			if ( binding.callback ) {
+				attrValue = binding.callback.apply(
+					binding.model,
+					binding.to.map( bound => {
+						return bound[ 0 ][ bound[ 1 ] ];
+					} )
+				);
+			}
 
-		for ( let boundAttrName of boundAttrs ) {
-			updateModelAttr( chain._bindModel, boundAttrName, value );
-		}
+			// A.bind( 'a' ).to( B )[ .to( N ) ];
+			//  \-> If multiple .to() models but **no** .as( callback ), then the binding is invalid.
+			else if ( binding.to.length > 1 ) {
+				attrValue = undefined;
+			}
+
+			// TODO: Needs update after https://github.com/ckeditor/ckeditor5-core/issues/132.
+			if ( binding.model.hasOwnProperty( binding.attr ) ) {
+				binding.model[ binding.attr ] = attrValue;
+			} else {
+				binding.model.set( binding.attr, attrValue );
+			}
+		} );
 	}
 }
 
 /**
- * Starts listening to changes in `chain._current.model` to update `chain._bindModel`
- * attributes. Also sets the initial state of `chain._bindModel` bound attributes.
+ * Starts listening to changes in `chain._lastToModel` to update `chain._model`
+ * attributes. Also sets the initial state of `chain._model` bound attributes.
  *
  * @private
  * @param {BindChain} chain The chain initialized by {@link Model#bind}.
  */
-function setupBinding( chain ) {
-	// If there's already a binding between the models (`chain._bindModel` listens to
-	// `chain._current.model`), there's no need to create another `change` event listener.
-	if ( !getModelBindingsToCurrent( chain ) ) {
-		chain._bindModel.listenTo( chain._current.model, 'change', ( evt, attrName, value ) => {
-			updateModelAttrs( chain, attrName, value );
+function setupBindToBinding( chain ) {
+	const lastToModel = chain._lastToModel;
+
+	// If there's already a chain between the models (`chain._model` listens to
+	// `chain._lastToModel`), there's no need to create another `change` event listener.
+	if ( !chain._model._boundModels.get( lastToModel ) ) {
+		chain._model.listenTo( lastToModel, 'change', ( evt, ...rest ) => {
+			updateModelAttrs( chain._model, lastToModel, ...rest );
 		} );
 	}
 
-	updateModelBindingsToCurrent( chain );
+	updateBoundAttributesAndModels( chain );
 
-	// Set initial model state.
-	chain._current.attrs.forEach( attrName => {
-		updateModelAttrs( chain, attrName, chain._current.model[ attrName ] );
+	// Synchronize initial state of `chain._model` with `chain._lastToModel`.
+	chain._lastToAttrs.forEach( attrName => {
+		updateModelAttrs( chain._model, lastToModel, attrName, lastToModel[ attrName ] );
 	} );
 }
 

+ 164 - 20
packages/ckeditor5-utils/tests/model.js

@@ -401,6 +401,21 @@ describe( 'Model', () => {
 				);
 			} );
 
+			it( 'should work when binding more that once', () => {
+				const vehicle = new Car();
+
+				vehicle.bind( 'color' ).to( car, 'color' );
+				vehicle.bind( 'year' ).to( car, 'year' );
+
+				assertBinding( vehicle,
+					{ 'color': car.color, year: car.year },
+					[
+						[ car, { color: 'blue', year: 1969 } ]
+					],
+					{ color: 'blue', year: 1969 }
+				);
+			} );
+
 			describe( 'as', () => {
 				it( 'should not chain', () => {
 					const car1 = new Car( { year: 1999 } );
@@ -432,7 +447,7 @@ describe( 'Model', () => {
 					vehicle.bind( 'type' )
 						.to( car1 )
 						.to( car2 )
-						.as( ( col1, col2 ) => col1 + col2 );
+						.as( ( ...args ) => args.join( '' ) );
 
 					expect( vehicle._attributes ).to.have.keys( [ 'type' ] );
 				} );
@@ -445,7 +460,7 @@ describe( 'Model', () => {
 					vehicle.bind( 'color' )
 						.to( car1 )
 						.to( car2 )
-						.as( ( col1, col2 ) => col1 + col2 );
+						.as( ( ...args ) => args.join( '' ) );
 
 					assertBinding( vehicle,
 						{ color: car1.color + car2.color, year: undefined },
@@ -465,7 +480,7 @@ describe( 'Model', () => {
 					vehicle.bind( 'color' )
 						.to( car1, 'color' )
 						.to( car2, 'color' )
-						.as( ( col1, col2 ) => col1 + col2 );
+						.as( ( ...args ) => args.join( '' ) );
 
 					assertBinding( vehicle,
 						{ color: car1.color + car2.color, year: undefined },
@@ -487,7 +502,7 @@ describe( 'Model', () => {
 						.to( car1 )
 						.to( car2 )
 						.to( car3 )
-						.as( ( col1, col2, col3 ) => col1 + col2 + col3 );
+						.as( ( ...args ) => args.join( '' ) );
 
 					assertBinding( vehicle,
 						{ color: car1.color + car2.color + car3.color, year: undefined },
@@ -509,7 +524,7 @@ describe( 'Model', () => {
 						.to( car1 )
 						.to( car2, 'lightness' )
 						.to( car3 )
-						.as( ( col1, lightness, col3 ) => col1 + lightness + col3 );
+						.as( ( ...args ) => args.join( '' ) );
 
 					assertBinding( vehicle,
 						{ color: car1.color + car2.lightness + car3.color, year: undefined },
@@ -520,6 +535,130 @@ describe( 'Model', () => {
 						{ color: 'blackbrightyellow', year: undefined }
 					);
 				} );
+
+				it( 'should work for a single attribute #5', () => {
+					const vehicle = new Car();
+					const car1 = new Car( { hue: 'reds' } );
+					const car2 = new Car( { lightness: 'bright' } );
+
+					vehicle.bind( 'color' )
+						.to( car1, 'hue' )
+						.to( car2, 'lightness' )
+						.as( ( ...args ) => args.join( '' ) );
+
+					assertBinding( vehicle,
+						{ color: car1.hue + car2.lightness, year: undefined },
+						[
+							[ car1, { hue: 'greens', year: 1930 } ],
+							[ car2, { lightness: 'dark', year: 1950 } ]
+						],
+						{ color: 'greensdark', year: undefined }
+					);
+				} );
+
+				it( 'should work when binding more that once #1', () => {
+					const vehicle = new Car();
+					const car1 = new Car( { hue: 'reds', produced: 1920 } );
+					const car2 = new Car( { lightness: 'bright', sold: 1921 } );
+
+					vehicle.bind( 'color' )
+						.to( car1, 'hue' )
+						.to( car2, 'lightness' )
+						.as( ( ...args ) => args.join( '' ) );
+
+					vehicle.bind( 'year' )
+						.to( car1, 'produced' )
+						.to( car2, 'sold' )
+						.as( ( ...args ) => args.join( '/' ) );
+
+					assertBinding( vehicle,
+						{ color: car1.hue + car2.lightness, year: car1.produced + '/' + car2.sold },
+						[
+							[ car1, { hue: 'greens', produced: 1930 } ],
+							[ car2, { lightness: 'dark', sold: 2000 } ]
+						],
+						{ color: 'greensdark', year: '1930/2000' }
+					);
+				} );
+
+				it( 'should work when binding more that once #2', () => {
+					const vehicle = new Car();
+					const car1 = new Car( { hue: 'reds', produced: 1920 } );
+					const car2 = new Car( { lightness: 'bright', sold: 1921 } );
+
+					vehicle.bind( 'color' )
+						.to( car1, 'hue' )
+						.to( car2, 'lightness' )
+						.as( ( ...args ) => args.join( '' ) );
+
+					vehicle.bind( 'year' )
+						.to( car1, 'produced' )
+						.to( car2, 'sold' )
+						.as( ( ...args ) => args.join( '/' ) );
+
+					vehicle.bind( 'mix' )
+						.to( car1, 'hue' )
+						.to( car2, 'sold' )
+						.as( ( ...args ) => args.join( '+' ) );
+
+					assertBinding( vehicle,
+						{
+							color: car1.hue + car2.lightness,
+							year: car1.produced + '/' + car2.sold,
+							mix: car1.hue + '+' + car2.sold
+						},
+						[
+							[ car1, { hue: 'greens', produced: 1930 } ],
+							[ car2, { lightness: 'dark', sold: 2000 } ]
+						],
+						{
+							color: 'greensdark',
+							year: '1930/2000',
+							mix: 'greens+2000'
+						}
+					);
+				} );
+
+				it( 'should work when binding more that once #3', () => {
+					const vehicle = new Car();
+					const car1 = new Car( { hue: 'reds', produced: 1920 } );
+					const car2 = new Car( { lightness: 'bright', sold: 1921 } );
+
+					vehicle.bind( 'color' )
+						.to( car1, 'hue' )
+						.to( car2, 'lightness' )
+						.as( ( ...args ) => args.join( '' ) );
+
+					vehicle.bind( 'custom1' ).to( car1, 'hue' );
+
+					vehicle.bind( 'year' )
+						.to( car1, 'produced' )
+						.to( car2, 'sold' )
+						.as( ( ...args ) => args.join( '/' ) );
+
+					vehicle.bind( 'custom2', 'custom3' ).to( car1, 'produced', 'hue' );
+
+					assertBinding( vehicle,
+						{
+							color: car1.hue + car2.lightness,
+							year: car1.produced + '/' + car2.sold,
+							custom1: car1.hue,
+							custom2: car1.produced,
+							custom3: car1.hue
+						},
+						[
+							[ car1, { hue: 'greens', produced: 1930 } ],
+							[ car2, { lightness: 'dark', sold: 2000 } ]
+						],
+						{
+							color: 'greensdark',
+							year: '1930/2000',
+							custom1: 'greens',
+							custom2: 1930,
+							custom3: 'greens'
+						}
+					);
+				} );
 			} );
 		} );
 	} );
@@ -592,7 +731,7 @@ describe( 'Model', () => {
 			model.bind( 'd', 'e' ).to( bound3, 'b3d', 'b3d' );
 
 			assertStructure( model,
-				{ a: true, b: true, c: true, d: true, e: true },
+				[ 'a', 'b', 'c', 'd', 'e' ],
 				[ bound1, bound2, bound3 ],
 				[
 					{ b1a: [ 'a' ] },
@@ -604,7 +743,7 @@ describe( 'Model', () => {
 			model.unbind( 'c', 'd' );
 
 			assertStructure( model,
-				{ a: true, b: true, e: true },
+				[ 'a', 'b', 'e' ],
 				[ bound1, bound2, bound3 ],
 				[
 					{ b1a: [ 'a' ] },
@@ -616,7 +755,7 @@ describe( 'Model', () => {
 			model.unbind( 'b' );
 
 			assertStructure( model,
-				{ a: true, e: true },
+				[ 'a', 'e' ],
 				[ bound1, bound3 ],
 				[
 					{ b1a: [ 'a' ] },
@@ -626,7 +765,7 @@ describe( 'Model', () => {
 
 			model.unbind();
 
-			assertStructure( model, {}, [], [] );
+			assertStructure( model, [], [], [] );
 		} );
 	} );
 
@@ -661,29 +800,34 @@ describe( 'Model', () => {
 		}
 	}
 
-	function assertStructure( model, expectedBound, expectedModels, expectedBindings ) {
-		const boundModels = [ ...model._boundTo.keys() ];
+	function assertStructure( model, expectedBoundAttributes, expectedBoundModels, expectedBindings ) {
+		const boundModels = [ ...model._boundModels.keys() ];
 
-		// Check model._bound object.
-		expect( model._bound ).to.be.deep.equal( expectedBound );
+		// Check model._boundAttributes object.
+		if ( expectedBoundAttributes.length ) {
+			expect( model._boundAttributes ).to.have.keys( expectedBoundAttributes );
+		} else {
+			expect( model._boundAttributes ).to.be.empty;
+		}
 
-		// Check model._boundTo models.
-		expect( boundModels ).to.have.members( expectedModels );
+		// Check model._boundModels models.
+		expect( boundModels ).to.have.members( expectedBoundModels );
 
 		// Check model._listeningTo models.
 		boundModels.map( boundModel => {
 			expect( model._listeningTo ).to.have.ownProperty( boundModel._emitterId );
 		} );
 
-		// Check model._boundTo model bindings.
+		// Check model._boundModels bindings.
 		expectedBindings.forEach( ( binding, index ) => {
 			const bindingKeys = Object.keys( binding );
 
-			expect( model._boundTo.get( expectedModels[ index ] ) ).to.have.keys( bindingKeys );
+			expect( model._boundModels.get( expectedBoundModels[ index ] ) ).to.have.keys( bindingKeys );
+
+			bindingKeys.forEach( key => {
+				const entries = [ ...model._boundModels.get( expectedBoundModels[ index ] )[ key ] ];
 
-			bindingKeys.forEach( k => {
-				expect( Array.from( model._boundTo.get( expectedModels[ index ] )[ k ] ) )
-					.to.have.members( binding[ k ] );
+				expect( entries.map( e => e.attr ) ).to.have.members( binding[ key ] );
 			} );
 		} );
 	}