This showcase presents how CrossEcore TypeScript can be used in a browser based Angular app with the Angular Material user interface or as a hybrid Android app with a Tabris user interface. This showcase was presented at the EclipeCon Europe 2018 in terms of the Modeling Symposium which is available on YouTube. It also shows how Ecore models can be stored in the document-based NoSQL database PouchDB. PouchDB synchronizes with a remote Apache CouchDB and stores the data locally on the end-user devices with the aid of the WebSQL adapter in the web browser and with the SQLite adapter in the hybrid app.
A Conference
consists of multiple Talks
.
A Talk
is associated with a Track
.
A Talk
has speakers
which are Persons
and the other way round a Person
can give a Talk
.
A Talk
has attendees
which are Persons
and the other way round a Person
can attend a Talk
.
A Person
has a firstName
and a lastName
.
A Person
worksFor
an Organization
.
A Talk
takes place in one Room
.
If you have worked with the Eclipse Modeling Framework before, you might be familiar with the default persistence technology XMI which reades from and writes to models as XML. As a side note, CrossEcore comes with an XMIResource that allows you to read and write your existing XMI models.
This showcase instead uses a document-based NoSQL database as persistence technology. This app has three memory layers: The first layer is a remote CouchDB database. The second layer is a local PouchDB database. The second layer allows the user to continue working even if the Internet connection is lost. CouchDB and PouchDB use JSON as serialization format. The Angular app uses the WebSQL adapter of PouchDB. The Tabris app uses the SQLite adapter of PouchDB. The model that is stored in-memory can be seen as a third, non-persistent layer.
PouchDB comes with a synchronization mechanism that keeps the remote and local database synchronized. The following code starts a continuous synchronization between the local database eclipsecon and the remote database http://localhost:5984/eclipsecon/.
PouchDB.sync('eclipsecon', 'http://localhost:5984/eclipsecon/', {
live: true,
retry: true
});
When the app starts, it initializes the sychronization. If the local database is up-to-date, the app iterates over all JSON documents and uses the Factory to create objects the objects and resolves cross-references. Subsequent changes from the remote database are continuously propagated to the second and third layer. The propagation of changes from the third layer to the upper layers is done by notifications/adapters that is described in the following section:
Notifications allow you to react on model changes.
You just need to implement the Adapter
interface and its notifyChanged
method.
Adapters are like listeners that listens to events fired by a notifier if the notifier changed.
The notifyChanged
method has an argument notification
.
The notification is an ENotificationImpl
object from which you can access the notifier (getNotifier()
), the event type (getEventType()
), the affected EStructuralFeature (getFeature()
), the new value of the feature after the change (getNewValue()
) and the old value before the change (getOldValue()
).
Objects that implement the Adapter interface needs to be added to the list of eAdapters of the notifier.
In the concrete case, the adapter in following example listen to changes to the user object.
This means the user object is the notifier.
Every time the user adds or removes talks from the list of attending talks, the notifyChange method is fired.
JsonResource.asJson()
serializes the user object as a JSON document and puts this document to the local PouchDB.
The local changes are automatically propagated to the remote CouchDB as the synchronization that was described in the previous section is still running in the background.
class MyAdapter implements Adapter{
notifyChanged(notification:Notification){
let notifier = notification.getNotifier();
if(notifier!==null){
let doc = new JsonResource().asJson(notifier);
new PouchDB('eclipsecon').put(doc)
.then(function (response) {
// handle response
}).catch(function (err) {
console.log(err);
});
}
}
}
this.user.eAdapters().push(new MyAdapter());
This example shows how to use the Ecore reflection API to realize a dynamic property editor. From a given object instance, the superordinate EClass
is determined. It iterates over all its EAttributes
(even inherited) and renders dynamically UI elements that correspond to the eType
of the respective EAttribute
.
The following code snipped illustrates how to use reflection within an Angular HTML template.
<div *ngFor="let attribute of user.eClass().eAllAttributes">
<mat-form-field *ngIf="attribute.eType.name==='EString'">
<input matInput [placeholder]="attribute.name" [value]="user.eGet(attribute)">
</mat-form-field>
<mat-form-field *ngIf="attribute.eType.name==='EInt'">
<input matInput [placeholder]="attribute.name" [value]="user.eGet(attribute)" type="number">
</mat-form-field>
<mat-form-field *ngIf="attribute.eType.name==='EDate'">
<input matInput [matDatepicker]="picker" [placeholder]="attribute.name" [value]="user.eGet(attribute)">
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
</mat-form-field>
<mat-slide-toggle *ngIf="attribute.eType.name==='EBoolean'" [checked]="user.eGet(attribute)===true">{{attribute.name}}</mat-slide-toggle>
</div>
When you use the factory to create objects, the factory will take care of proper object registration that are necessary such that features like reflection and the OCL operations allInstances()
will work properly.
For example, you call ConferenceFactoryImpl.eINSTANCE.createTalk()
to create a new Talk
object (instead using the new
operator).
The following code shows how to use the factory in TypeScript:
let talk: Talk = ConferenceFactoryImpl.eINSTANCE.createTalk();
A Person
attends
Talks
and Talks
have multiple Persons
that attend
.
Whenever a Talk
is added to the list of Talks
a Person
attends, this Person
needs to be added to the list of attendees
of this Talk
to keep this bi-directional association consistent.
One can use the eOpposite
feature to model such kind of bi-directional associations.
If you establish an association in one direction, the API keeps the other direction consistent automatically for you.
The following TypeScript code illustrates how to keep the associations between Persons
and Talks
consistent:
let person:Person = ConferenceFactoryImpl.eINSTANCE.createPerson();
let talk:Talk = ConferenceFactoryImpl.eINSTANCE.createTalk();
person.attends.add(talk);
console.log(talk.attendees.includes(person)); //returns true
console.log(person.attends.includes(talk)); //returns true
talk.attendees.remove(person);
console.log(person.attends.excludes(talk)); //returns true
console.log(talk.attendees.excludes(person)); //returns true
The OCL invariant noConflict
asserts that all Talks
a Person
self attends
are not temporally overlapping.
invariant noConflict:
self.attends
->forAll(t1:Talk | self.attends
->forAll(t2:Talk|
(t1.timeBegin < t2.timeBegin and
t1.timeEnd <= t2.timeBegin)
or
(t2.timeBegin < t1.timeBegin and
t2.timeEnd <= t1.timeBegin)
)
);
The corresponding code in TypeScript looks as follows:
this.attends
.forAll(t1 => this.attends
.forAll(
t2 => (t1.timeBegin < t2.timeBegin &&
t1.timeEnd <= t2.timeBegin)
||
(t2.timeBegin < t1.timeBegin &&
t2.timeEnd <= t1.timeBegin)
)
);
The query meetPersonAt(other:Person):Talk
returns all Talks
where Persons
self and other will meet, no matter if they are a speaker
or attendee
.
Talk.allInstances()
->select(t:Talk |
(t.speakers->includes(self) or
t.attendees->includes(self))
and
(t.speakers->includes(other) or
t.attendees->includes(other))
);
TalkImpl.allInstances()
.select(t =>
(t.speakers.includes(this) ||
t.attendees.includes(this))
&&
(t.speakers.includes(other) ||
t.attendees.includes(other)
)
);
A Conference
consists of Talks
and Tracks
.
A Track
has multiple Talks
.
The other way round, a Talk
is assigned to a Track
.
This means the connection of a Conference
to a certain Talk
self needs to be maintained as well as a connection between a Track
and Talk
self.
If the self object would be stored redundantly in two separate list of a Conference
and a Track
, this would unnesessarily increase memory consumption and require that copies of self are kept consistent.
Derived attributes calculate values, objects or collections by an OCL calculation rule. The navigating expression are a simple form of model queries that do not have additional call arguments.
The OCL expression that selects all Talks
assigned to a Track
looks like this:
self.conference.talks->select(t:Talk|t.track = self);
The variable self points to a given Talk
.
The expression navigates over the Conference
the Talk
is contained in.
Then it iterates over all the Conference
's Talks
and selects the Talks
whose Track
is self.
CrossEcore's OCL compiler automatically translates this OCL expression into a expression of the target language, e.g. TypeScript. This is how the corresponding TypeScript expression looks like:
this.conference.talks.select(t => t.track == this);
As explained in the beginning, the app stores the model in a PouchDB and synchronizes it with a remote CouchDB. When you start the app the first time, it creates the PouchDB and imports the contents from a CSV file. The app even runs out of the box without the CouchDB.
If you want to use the CouchDB synchronization here is what you have to do: By default, the app expects the remote CouchDB to be available at http://localhost:5984 and that the database eclipsecon exists. You can install CouchDB on your local computer by following the CouchDB installation instructions. Once you have installed it, you can open Fauxton, the user interface of CouchDB that is available at http://localhost:5984/_utils/ to create the database eclipsecon.
When you want to connect to the CouchDB from the Tabris app be sure that you configure a network address that is accessible from the mobile device and that you have proper firewall settings (port 5984).
Install Node.js. Open a command line interface and install Angular CLI via the Node.js Package Manager (npm).
npm install angular-cli -g
Change the working directory to angular-app/.
cd angular-app
npm install
Starting a web server and open the app in a browser.
ng serve --open
The Tabris documentation has a detailed section about the Tabris build process.
You can install the Tabris command line tools via npm:
npm install -g tabris-cli
Take a look at the cordova configuration file cordova/config.xml
.
In order to use the PouchDB SQLite adapter you need the cordova plugin cordova-plugin-sqlite-2
.
<plugin name="cordova-plugin-sqlite-2" spec="^1.0.5" />
To allow your app to make XHR calls to the remote CouchDB you need the plugin cordova-plugin-whitelist
.
<plugin name="cordova-plugin-whitelist" spec="^1.3.3" />
In addition, you need to configure the network access:
<access origin="*" />
To build your app, enter the following command in the command line:
tabris build android