Write Transactions and Queries
Base API
Cloud API
The goal of this section is to help you write code to manipulate your data using new features of the CODE API.
We first go through an example Data Model to use with examples.
Your own setup
- Create a new Data Model with these 3 Data types, their relations and attributes
- Create the 3 JS classes corresponding to these data types. You can do so by clicking the three dots, and “Generate JS boilerplate ”. This gives you 3 files:
Address.js
,Pet.js
andPerson.js
. - Store these files in a suitable folder so that you can reference them from your coded bricks.
- Create a coded action brick where we are going to write & execute transactions. Place it appropriately, so it can be built with your project and be accessed by the JS boilerplate classes.
- In the JS class for your coded action, import the relevant JS boilerplate classes.
Write the code excerpts presented in the update
method of your coded action brick.
Create data
Let's now see how to create instances and relations between them.
Create an instance
Create an instance without properties
- The
Address
class must have been implemented, see setup section. - the
Transaction
class must be imported in your hardcoded brick file. If your IDE did not do it automatically, addTransaction
to the list of imported symbols from'olympe'
, like so :
import {ActionBrick, registerBrick, Transaction} from 'olympe';
Let's start with a simple example on how to create a first instance for the Address
data type.
const t = Transaction.from($);
Address.create(t);
t.execute().then(() => forwardEvent());
This will create an instance of Address
without any of its properties set. (They are undefined)
Let’s unpack this example. Transaction.from($)
gets an existing transaction, or creates a new one. It is good practice to use it. If there is an already existing transaction in this context (think of Begin/End bricks starting a transaction), you’d get this transaction from Transaction.from
otherwise, a new one is created. The $
symbol is the update
argument containing the Brick Context
and comes with the boilerplate code for a hardcoded brick.
Address.create(t)
creates the empty instance (i.e., without any properties set) of Address
in the transaction t
.
Address.create(t)
is equivalent to t.create(Address)
To finish a transaction and execute it, an asynchronous call to execute()
is necessary. One should process the success / the failure of the transaction using the Promise style. The .then
callback is executed when the transaction succeeds and the .catch
callback is executed when the transaction fails. In our case, the transactions triggers the output control flow of our coded action brick only when the transaction succeeds.
Create an instance with properties
In this section, we see how to give properties values to an Address
instance we create.
Set the properties directly at the instance creation with transaction.create(model, propertiesMap)
const t = Transaction.from($);
const properties = new Map();
properties.set(Address.nameProp, 'Lausanne');
properties.set(Address.zIPCodeProp, 1007);
t.create(Address, properties);
t.execute().then(() => forwardEvent());
At instance creation, properties can be transferred via a Map
to the instance creation operation. Multiple equivalent ways to do so exist; t.create(Address, properties)
is equivalent to :
const newTag = t.create(Address);
t.multiUpdate(newTag, properties);const newTag = t.create(Address);
t.update(newTag)const newTag = t.create(Address);
t.update(newTag, Address.nameProp, 'Lausanne');
t.update(newTag, Address.zIPCodeProp, 1007);
The other ways are equivalent and use the methods update
and multiUpdate
. Both methods update the given instance (the first tag
parameter), provided the values as the next arguments.
Update an instance property
As shown in the precedent section, an instance properties can be updated. It is possible to make an update on any instance (new or already existing), provided the instance or its tag. The method update
requires the instance, its property we want to modify, and the new value of it. In the case of multiUpdate
, the arguments are the instance, and then a map having properties as keys,
Update an instance with the update
and multiUpdate
operations.
Examples:
transaction.update(tag, property, value);
or
transaction.multiUpdate(instance, propertiesMap);
Create relationships
This section explains how to link instances with each other with relations defined on your data types.
The next example will create a Address
and a Person
and link the former to the latter with the relationship address
in the Data Model in the setup section.
The createRelation
methods takes 3 arguments in this order: the relation to create, the instance the relation starts from, the instance the relation ends at.
const t = Transaction.from($);
const addressProperties = new Map();
addressProperties.set(Address.nameProp, 'Lausanne');
addressProperties.set(Address.zIPCodeProp, 1007);
const newAddressTag = t.create(Address, addressProperties);
const newPersonTag = t.create(Person);
t.update(newPersonTag,Person.nameProp, 'Jane')
.createRelation(Person.addressRel, newPersonTag, newAddressTag)
.execute().then(() => forwardEvent());
transaction.create(...)
returns a string tag whereas other operations on transactions return the modified transaction. DO NOT chain calls to transaction.create()
Use the returned string tag to create relations between instances, modify the objects, etc...
Let us do a more complex example with more relationships. We create multiple Person
, an Address
and a Pet
instances.
<a id="exampleDataCreation"></a>
const t = Transaction.from($);
const addressProperties = new Map();
addressProperties.set(Address.nameProp, 'Lausanne');
addressProperties.set(Address.zIPCodeProp, 1007);
const newAddressTag = t.create(Address, addressProperties);
const personTag = t.create(Person, new Map([[Person.nameProp, 'Alice']]));
const firstPetTag = t.create(Pet, new Map([[Pet.nameProp, 'Rex']]));
const secondPetTag = t.create(Pet, new Map([[Pet.nameProp, 'Puss']]));
t
.createRelation(Person.addressRel, personTag, newAddressTag)
.createRelation(Person.ownsRel, personTag, firstPetTag)
.createRelation(Person.ownsRel, personTag, secondPetTag);
return t.execute().then(() => forwardEvent());
- save tags of new instances to re-use them for other operations
- chain transactions operations other than
create
Querying data
You must import the Query
class for this part in your hardcoded brick file
Let us retrieve with Queries
data we created in the previous example. This section is covered by the class Query in the cloud.d.ts
API.
A Query
in Olympe is a graph query builder. The graph on which the query operates is made of nodes. For a simple and quick understanding, each CloudObject
or data type instance is represented by a node. Example of relations between nodes can be the relations we created in the last example. When creating a Query
, you will first define which nodes in the graph it starts from. Let us examine that through an example.
The query Query.from(personTag).follow(addressRel)
defines the starting node(s) to be the instance with the tag personTag
. When following a relation with a query, the specified relation leads to a new set of one (or many) node(s). In our example, following the addressRel
relation leads you to the node with tag newAddressTag
.
A Query
selects instances part of a path in the graph. It uses a specified starting node and defines paths from this starting node with relations.
The Query
can start from multiple nodes, see here
Query results
The execution of queries will retun objects of type QueryResult
. It represents the result of a query, and contrary to our old API, it is de-correlated from the Query
, i.e., if the Query
is updated, any result we got beforehand does not change.
A query result is a list of key-values pairs. The values are instances of a data type, and they satisfy the conditions imposed by the query. The keys are the tags of the corresponding value instance(s).
QueryResult
is de-correlated from the Query
that generated it. Manipulate it easily as an independent result object.
Operations on query results
Multiple operations exist in the Cloud (cloud.d.t.s
) API to use a QueryResult
.
From an executed Query
, you get a QueryResult
, e.g., myQueryResult
. How to use this object?
To simply get the result instances from the query:
const myInstances = myQueryResult.toArray();
This method yields an array with the instances without any pre-processing. Multiple methods allow you to obtain a transformed result array:
const withoutFirstElement = myQueryResult.shift();
const withoutLastElement = myQueryResult.pop();
const mappedElements = myQueryResult.map(callback);
const reducedValue = myQueryResult.reduce(reducer);
More transformations exist: check the Cloud API under QueryResult
(cloud.d.ts
).
Get new modified result arrays (the operations do not modify the query result itself!) with various operations such as : map
, reduce
, sort
, etc...
Query instances of a data type (also called model)
We present here a very common pattern in the Olympe environment: retrieving the instances of a Data Type.
For this example, we retrieve all the instances of a Person
:
const myInstancesQuery = Query.instancesOf(Person);
A data type is the only argument necessary to write this Query
. A Data type, in the context of our example, is just the name of a JS class whose boilerplate code was generated from DRAW.
Do not forget to import the class whose name you use in a Query
where the query is written.
Query.instancesOf(Person)
is equivalent to Person.instancesOf()
but more concise!
Query relations between data types
As introduced earlier, the class Query
is a graph query builder. We examined through two examples how to start a Query
. Remember the code to do so:
const query1 = Query.from(childTag);
const query2 = Query.instancesOf(Person);
The next step is to define a path in the graph, where each step is done by following a relation. For example:
const query1 = Person.instancesOf().follow(Person.ownsRel);
const query2 = Pet.instancesOf().follow(Person.ownsRel.getInverse()).follow(Person.addressRel);
The first query retrieves the instances of the Person
data type, and follow the ownsRel
as a first path step. The result contains all the pets owned by anyone.
The second query retrieves all the pets, finds their owner, finds the address of the owners. The result contains all the addresses in which pet owners live. Note the relations of pet ownership is followed in the opposite direction.
Use follow(myRelation.getInverse())
to follow a relation A->B from B to A.
Join operations
Up to this section, queries always return the result at the end of the path the queries define. With the andReturn()
operation, one can set what instances are part of the result.
For the sake of our example, assume that Jane has two pets, Bob has one and Alice has zero.
The query :
const query = Person.instancesOf().andReturn().follow(Person.ownsRel).andReturn();
specifies that both the owner and the owned pet are returned. For our example, the results are going to be:
result = [['Jane', 'Rex'], ['Jane', 'Rouky'], ['Bob', 'Rufus']];
Notice that Alice does not show up in the result, as she does not own any pet. It is also possible to only return the pet owners, without the pet. The query and its result are then:
const query = Person.instancesOf().andReturn().follow(Person.ownsRel);
result = ['Jane', 'Bob'];
Multiple noticeable details:
- having 2
andReturn
operations simulated a join between pets and owners on the ownership relation. - when multiple
andReturn
are used, theQueryResult
values array is a list of tuples. If there are only one type of instances returned, the values array is a list of instances! - using a
follow()
operation after anandReturn
operation means that only nodes that are participating in the relation specified byfollow
are returned.
- use multiple
andReturn
to simulate JOINs - filter nodes based on relation participation using combinations of
andReturn
thenfollow
Apply filters, sort and limit operations
Let us go through some other examples and operations that can be applied on queries:
Query.instancesOf(Person).filter(Predicate.contains(Person.nameProp, 'ane', false)).andReturn().follow(Person.ownsRel);
This query filters all the Person
instances that do not contain the substring 'ane'
, the new result value array is: ['Jane']
.
To make a predicate, import the Predicate
class from 'olympe'
and use the various propositions: greaterThan
, contains
, equals
etc...
Predicates usually act on the property of instances, which has to be specified in the predicate. They can be composed with Predicate.and
, Predicate.or
and Predicate.not
operators.
Similarly, operations like sort
and limit
can be defined. sort
is made using one property, and the symbols from Order
(importable from 'olympe'
) can be used to specify in which direction the sort is applied.
- The
limit
operator applies to the final list of result, not to an individual level. Only calllimit
once perQuery
. - The
sortBy
operator can be applied only once on aQuery
. Multiple call make the previous one not have any effect. Only callsortBy
once perQuery
.
Query execution
This section explains how to transition from a Query
to a QueryResult
.
In the Cloud API (cloud.d.ts
), in the Query
class one can see three methods returning a QueryResult
:
query.executeFromCache(): QueryResult
query.execute(): Promise<QueryResult>
query.observe(): Observable<QueryResult>
The three methods differ in two main aspects:
- the data on which the query is executed
- when the result is available
The method executeFromCache()
returns synchronously a QueryResult
. The query is executed immediately with the data present in the local cache of your browser, i.e., data that has already been fetched by your application in the past, or that has been created locally.
The method execute()
returns a Promise<QueryResult>
. The obtained QueryResult
can be obtained asynchronously with the JS Promise-style callbacks : then((queryRes) => {...})
for a successful query, .catch((messageError) => {...})
for an eventual failed Query
. The query is executed by the entity responsible to persist the data on which the query is made. This way, you ensure that if any other Olympe VM made a change to your data and persisted it, your query will see that change.
The method observe()
is similar to execute()
: it is the same entity that resolves the query, but this entity will watch for changes on the data on which the query is executing. If a change was to happen, another QueryResult
is pushed to the Observable
.
A step further
Now that you have gone through this first exercise, let's go through an extension.
Create a brick that takes the following JSON as an input, and performs the following:
- If the Person is missing, create it
- If the Pet is missing, create it
- If the relation is missing create it
- If the species is updated, update it
Here is the signature of the brick:
The inputs and outputs are:
You can use the following dataset as input. This should lead to:
- There are 3 persons: Pamela, Anne, Pierre
- There are 3 pets: Rex the dog, Nemo the turtle and Brutus the hamster
- Pamela owns Rex, Anne owns both Nemo and Brutus, and Nemo is owned by both Pierre and Anne
[
{
"owner": "Pamela",
"pet name": "Rex",
"species": "dog"
},
{
"owner": "Anne",
"pet name": "Nemo",
"species": "turtle"
},
{
"owner": "Anne",
"pet name": "Brutus",
"species": "hamster"
},
{
"owner": "Pierre",
"pet name": "Nemo",
"species": "turtle"
}
]
Then you can check that the update works properly by processing the following input. This should lead to the following changes:
- Pamela has a second pet: Puss
- Nemo is actually a fish, not a turtle.
- There is a new Person (Bahj), and a new Pet (Nilti)
[
{
"owner": "Pamela",
"pet name": "Rex",
"species": "dog"
},
{
"owner": "Anne",
"pet name": "Nemo",
"species": "fish"
},
{
"owner": "Pamela",
"pet name": "Puss",
"species": "cat"
},
{
"owner": "Anne",
"pet name": "Brutus",
"species": "hamster"
},
{
"owner": "Pierre",
"pet name": "Nemo",
"species": "fish"
},
{
"owner": "Bahj",
"pet name": "Nilti",
"species": "dog"
}
]