Skip to content

Commit

Permalink
Add latest features
Browse files Browse the repository at this point in the history
Contributed on behalf of STMicroelectronics

Co-authored-by: Christian W. Damus <[email protected]>
Co-authored-by: Anthony Fusco <[email protected]>
Co-authored-by: Camille Letavernier <[email protected]>
Co-authored-by: Stefan Dirix <[email protected]>
Co-authored-by: Maxime DORTEL (STM) <[email protected]>
Co-authored-by: Nina Doschek <[email protected]>
Co-authored-by: Gabriel GASNOT <[email protected]>
Co-authored-by: FlorentPastorSTM <[email protected]>

* feat: Deferred compound commands API
Implement a new API for construction of late-defined compound
commands, in which
- the scope of model edits is declared up-front to exclude concurrent
  commands that would interfere
- executability of the compound does not require the commands to be
  known a priori
- a separate can-execute predicate can be provided that asserts the
  executability of the commands without actually having to prepare them
- a strict option does allow to prepare the compound for executability
  test delegating to its contained commands
- document the primary use case for the deferred compound command

* fix: clear frontend subscriptions on modelhub dispose
Previously, when a ModelHub was disposed in the backend, the cached
frontend pipelines were not cleared. Therefore a subscription to a now
no longer existing ModelHub was reused when a ModelHub with the same
context was created. As a consequence new frontend subscription to the
following ModelHubs were not notified. This is now fixed.
  • Loading branch information
eneufeld committed Oct 31, 2024
1 parent 37e200f commit 23f3737
Show file tree
Hide file tree
Showing 20 changed files with 1,382 additions and 69 deletions.
88 changes: 80 additions & 8 deletions docs/guides/model-management-user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ This document provides scenario-oriented guidance for adopters of the Model Mana
- [Advanced Command Scenarios](#advanced-command-scenarios)
- [Executing a Command on Multiple Stacks](#executing-a-command-on-multiple-stacks)
- [Command Stack Subscriptions](#command-stack-subscriptions)
* [Deferred Composition of Commands](#deferred-composition-of-commands)
- [Model Validation Service](#model-validation-service)
- [Model Validation Subscriptions](#model-validation-subscriptions)
- [Trigger Engine](#trigger-engine)
Expand Down Expand Up @@ -357,6 +358,7 @@ import {
CommandStack,
append,
createModelUpdaterCommandWithResult,
unwrapReturnResult
} from '@eclipse-emfcloud/model-manager';

const alice: AddressEntry = {
Expand Down Expand Up @@ -399,15 +401,18 @@ await stack.execute(addAlice);
And then let us see what the result of this command is:

```typescript
console.log('Inserted entry at index', addAlice.result!.index);
const result = unwrapReturnResult(addAlice);
console.log('Inserted entry at index', result!.index);

// Inserted entry at index 0
```

Well, yes of course, because the address book was initially empty, the index at which this entry was inserted could only be zero.

Until the command is executed, its `result` property will be `undefined`.
After successful execution, it will have whatever value was returned by the model updater function.
Until the command is executed, its `result` property will be a `PendingResult` and unwrapping it yields `undefined`.
After successful execution, it will be a `ReadyResult` that contains whatever value was returned by the model updater function.
In the event that the execution fails with an error, the command's return result will be a `FailedResult` that contains the error
and unwrapping the command's return result rethrows that error.

These commands can be composed into larger units just like any others:

Expand All @@ -424,8 +429,8 @@ const addCathy = addAddressEntry({
});
await stack.execute(append(addBobbi, addCathy));

console.log('Bobbi inserted at index', addBobbi.result!.index);
console.log('Cathy inserted at index', addCathy.result!.index);
console.log('Bobbi inserted at index', unwrapReturnResult(addBobbi)!.index);
console.log('Cathy inserted at index', unwrapReturnResult(addCathy)!.index);

// Bobby inserted at index 0
// Cathy inserted at index 1
Expand All @@ -437,9 +442,9 @@ As a special case, commands may be composed in a chain where the results of earl

[back to top ↩︎](#contents)

As JSON patch is only defined for JSON documents, it lacks specification pertaining to `undefined` values.
As JSON patch is only defined for JSON documents; it lacks specification pertaining to `undefined` values.
While [fast-json-patch has some capabilities to work around `undefined` values][fast-json-patch-undefined], sadly they are not sufficient out of the box.
Its modification operations are handled according to the workaround, however the generated "test" operations will fail as they can't test for `undefined` values.
Its modification operations are handled according to the work-around, however the generated "test" operations will fail as they can't test for `undefined` values.
As a consequence, working with fast-json-patch directly might lead to non-applicable patches in case such `undefined` values are part of the used models.

To avoid these problems, the user might only want to operate on and modify their models in a JSON compatible way.
Expand Down Expand Up @@ -672,7 +677,7 @@ In review, this code sample illustrates several noteworthy points:

[back to top ↩︎](#contents)

In addition to make dependent changes in other models when we execute commands to change our own, oftentimes the inverse is true: we need to makes changes to _our_ models when we detect changes in some other related models.
In addition to making dependent changes in other models when we execute commands to change our own, often the inverse is true: we need to makes changes to _our_ models when we detect changes in some other related models.

#### Model Manager Subscriptions

Expand Down Expand Up @@ -1603,6 +1608,73 @@ Undo dependencies work exactly the same as redo dependencies, where in order to
By default, undo and redo try implicitly to include dependencies for convenience.
In applications in which this can result in surprising side-effects, the analysis operations of the `CoreCommandStack` API provide the dependency information that can be presented to users in the UI to obtain informed consent to include dependencies, or to indicate specifically why undo or redo are not available even accounting for dependencies.

#### Deferred Composition of Commands

<details>
<summary>Source Code</summary>

The example code in this section may be found in the repository in the [`@example/model-management` package's `model-commands-advanced.ts` script](../../examples/guide/model-management/src/model-commands-advanced.ts) and may be run by executing `yarn example:advancedcommands` in a terminal.
</details>

Occasionally it can be difficult, or expensive, to determine _a priori_ all of the edits required to perform some logical change to the user's data and construct a complex command that may never be executed.
This is especially common when dealing with commands that span multiple models with complex interdependencies.
For these cases, the framework provides _Deferred Compound Commands_ that defer all the work of creating their nested commands until it is actually time to execute them.

A relatively straight-forward case is the execution of commands that may or may not have dependencies met in the model, where in the case that they are not met, we also want to include commands to satisfy those dependencies.
For example, when adding a `Shipment` to our example package-tracking model, we want to ensure that the recipient exists in the `AddressBook` model and also that that recipient has in its list of addresses the address to which we are sending the package.

To accomplish this, we can create a deferred compound command and tell it, via a list of model IDs declared up-front, that it will be editing the package-tracking model and the address-book model.
It doesn't matter whether some of these declared models don't end up needing to be modified by any commands that we will add to the composite; the benefit is that our command provider and consequently the compound command will have exclusive access to these models with the guarantee that they will not change concurrently, in case we do need to edit them.

```typescript
import { AddressBook, AddressEntry, getAddressBookEntryWithPointer, hasAddressMatching } from './address-book';
import { Shipment } from './package-tracking';
import { createDeferredCompoundCommand } from '@eclipse-emfcloud/model-manager';

const addShipmentWithDependenciesCommand = createDeferredCompoundCommand(
'Add all the Things',
['example:contacts.addressbook', 'example:packages.shipping'],
function* (getModel) {
const addressBook = getModel<AddressBook>('example:contacts.addressbook');
if (!addressBook) {
throw new Error('No address book found.');
}

// Do we need to add an entry? If so, yield it and it will be added to the compound.
const existingEntry = getAddressBookEntryWithPointer(addressBook, 'Brown', 'Alice');
if (!existingEntry) {
const entryToAdd: AddressEntry = { lastName: 'Brown', firstName: 'Alice', addresses: [] };
yield createAddEntryCommand(entryToAdd);
}

const shipmentToAdd: Shipment = {
recipient: { lastName: 'Brown', firstName: 'Alice' },
shipTo: {
numberAndStreet: '123 Front Street',
city: 'Exampleville',
province: 'Ontario',
country: 'Canada',
},
};

// We're adding a shipment. Do we also need to add the address?
// Note that if there wasn't an existing entry, we'd have to add
// the address because we created the new entry without any address
if (!existingEntry || !hasAddressMatching(existingEntry[0], shipmentToAdd.shipTo)
) {
yield createAddAddressCommand(shipmentToAdd.recipient.lastName, shipmentToAdd.recipient.firstName,
{ kind: 'home', ...shipmentToAdd.shipTo });
}

// And, finally, the command to add the actual shipment
yield createAddShipmentCommand(shipmentToAdd);
}
);
```

Note how the example above uses a JavaScript generator function to provide the commands to include in the composite, using a convenient sequential programming model with conditions to yield commands one by one as they are determined to be necessary and are constructed.
The deferred compound command also supports asynchronous provision of the commands and individual commands being returned as promises, for example from other APIs that asynchronously create them.

#### Command Stack Subscriptions

The `CommandStack` API provides subscriptions for notification of events in its operation, including
Expand Down
16 changes: 16 additions & 0 deletions examples/guide/model-management/src/address-book.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,19 @@ export function getAddressBookEntryWithPointer(
}
return undefined;
}

export function hasAddressMatching(
entry: AddressEntry,
pattern: Partial<Address>
): boolean {
const matches = (address: Address): boolean => {
for (const [key, value] of Object.entries(pattern)) {
if (address[key as keyof Address] !== value) {
return false;
}
}
return true;
};

return entry.addresses.some(matches);
}
128 changes: 128 additions & 0 deletions examples/guide/model-management/src/model-commands-advanced.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
Command,
ModelManager,
append,
createDeferredCompoundCommand,
createModelManager,
createModelUpdaterCommand,
} from '@eclipse-emfcloud/model-manager';
Expand All @@ -25,6 +26,7 @@ import {
AddressBook,
AddressEntry,
getAddressBookEntryWithPointer,
hasAddressMatching,
} from './address-book';
import { PackageTracking, Shipment } from './package-tracking';

Expand Down Expand Up @@ -492,15 +494,141 @@ async function commandStackSubscription(modelManager: ModelManager<string>) {
dispose();
}

async function addShipmentWithNewDeliveryAddressDeferred(
modelManager: ModelManager<string>
) {
const addShipmentWithDependenciesCommand = createDeferredCompoundCommand(
'Add all the Things',
['example:contacts.addressbook', 'example:packages.shipping'],
function* (getModel) {
const addressBook = getModel<AddressBook>('example:contacts.addressbook');
if (!addressBook) {
throw new Error('No address book found.');
}

// Do we need to add an entry? If so, yield it and it will be added to the compound.
const existingEntry = getAddressBookEntryWithPointer(
addressBook,
'Brown',
'Alice'
);
if (!existingEntry) {
const entryToAdd: AddressEntry = {
lastName: 'Brown',
firstName: 'Alice',
addresses: [],
};
yield createAddEntryCommand(entryToAdd);
}

const shipmentToAdd: Shipment = {
recipient: {
lastName: 'Brown',
firstName: 'Alice',
},
shipTo: {
numberAndStreet: '123 Front Street',
city: 'Exampleville',
province: 'Ontario',
country: 'Canada',
},
};

// We're adding a shipment. Do we also need to add the address?
// Note that if there wasn't an existing entry, we'd have to add
// the address because we created the new entry without any address
if (
!existingEntry ||
!hasAddressMatching(existingEntry[0], shipmentToAdd.shipTo)
) {
yield createAddAddressCommand(
shipmentToAdd.recipient.lastName,
shipmentToAdd.recipient.firstName,
{ kind: 'home', ...shipmentToAdd.shipTo }
);
}

// And, finally, the command to add the actual shipment
yield createAddShipmentCommand(shipmentToAdd);
}
);

const addressBookStack = modelManager.getCommandStack('address-book');
await addressBookStack.execute(addShipmentWithDependenciesCommand);

let addressBook = modelManager.getModel<AddressBook>(
'example:contacts.addressbook'
);
let packageTracking = modelManager.getModel<PackageTracking>(
'example:packages.shipping'
);

console.log('Contacts address book:', inspect(addressBook));
console.log('Package tracking:', inspect(packageTracking));

// Contacts address book: {
// entries: [
// {
// lastName: 'Brown',
// firstName: 'Alice',
// addresses: [
// {
// kind: 'home',
// numberAndStreet: '123 Front Street',
// city: 'Exampleville',
// province: 'Ontario',
// country: 'Canada'
// }
// ]
// }
// ]
// }
// Package tracking: {
// shipments: [
// {
// recipient: { lastName: 'Brown', firstName: 'Alice' },
// shipTo: {
// numberAndStreet: '123 Front Street',
// city: 'Exampleville',
// province: 'Ontario',
// country: 'Canada'
// }
// }
// ]
// }

await addressBookStack.undo();

addressBook = modelManager.getModel<AddressBook>(
'example:contacts.addressbook'
);
packageTracking = modelManager.getModel<PackageTracking>(
'example:packages.shipping'
);

console.log('Contacts address book:', inspect(addressBook));
console.log('Package tracking:', inspect(packageTracking));

// Contacts address book: { entries: [] }
// Package tracking: { shipments: [] }
}

async function main() {
console.log('--- Broken scenario ---');
let modelManager = createModels();
await addShipmentWithNewDeliveryAddressBroken(modelManager);

console.log('--- Fixed scenario ---');
modelManager = createModels();
await addShipmentWithNewDeliveryAddressFixed(modelManager);

console.log('--- Command-stack subscription ---');
modelManager = createModels();
await commandStackSubscription(modelManager);

console.log('--- Deferred compound command ---');
modelManager = createModels();
await addShipmentWithNewDeliveryAddressDeferred(modelManager);
}

main();
4 changes: 3 additions & 1 deletion examples/guide/model-management/src/model-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ModelManager,
createModelManager,
createModelUpdaterCommandWithResult,
unwrapReturnResult,
} from '@eclipse-emfcloud/model-manager';
import { AddressBook, AddressEntry } from './address-book';

Expand Down Expand Up @@ -84,7 +85,8 @@ async function addEntryToAddressBook(modelManager: ModelManager<string>) {

const addAlice = addAddressEntry(alice);
await stack.execute(addAlice);
console.log('Inserted entry at index', addAlice.result!.index);
const result = unwrapReturnResult(addAlice);
console.log('Inserted entry at index', result!.index);
console.log('Contacts address book:', inspect(addressBook));

// Inserted entry at index 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,43 @@ describe('FrontendModelHub', () => {
sinon.assert.calledWithMatch(onModelChanged, MODEL1_ID, MODEL1, patch);
});

it('subscription sees consistent model', async () => {
const sub = await modelHub.subscribe();

let sawCorrectModelId = false;
let sawCorrectModelObject = false;

const gatherAssertions: (
modelId: string,
model: object
) => Promise<void> = async (modelId, model) => {
sawCorrectModelId = modelId === MODEL1_ID;
const currentModel2 = await modelHub.getModel(modelId);
sawCorrectModelObject = currentModel2 === model;
};

let assertions: Promise<void> = Promise.reject(
new Error('onModelChange not called')
);

sub.onModelChanged = (modelId, model) =>
(assertions = gatherAssertions(modelId, model));

const patch: Operation[] = [
{ op: 'replace', path: '/name', value: MODEL1.name },
];
fake.fakeModelChange(MODEL1_ID, patch);
await asyncsResolved();

await assertions;
expect(sawCorrectModelId, 'incorrect model ID in subscription call-back')
.to.be.true;
expect(
sawCorrectModelObject,
'incorrect model retrieved from hub during subscription call-back'
).to.be.true;
});

it('notifies dirty state', async () => {
const sub = await modelHub.subscribe(MODEL1_ID);
sub.onModelDirtyState = onModelDirtyState;
Expand Down
Loading

0 comments on commit 23f3737

Please sign in to comment.