You create projection schemas by using the graphql schema language.
The name of the projection is the name of your type. In the following example the projection will be called User
:
type User {
...
}
Supported Types:
- String
- ID
- Boolean
- Int
- Float
- DateTime (unix timestamp, milliseconds)
- Arrays
- Objects / references to other projections
- Enums
- All types listed above in their required form
The field type [EventEnvelope!]!
is a special field type that can only be used in this form at the root level of a projection (not in a nested object). It will automatically aggregate all events that are applied on the projection into an array.
You can define Enums and use them in your schema:
enum YourEnum {
YOUR_VALUE
OTHER_VALUE
}
You can use the name of an other Projection as field type in order to save a relation to an other projection. You can also define a nested type:
type User {
friends: [User!]! # This is an array references to other users
nested: Profile! # This is a nested object field this field does not reference to an other projection, it directly contains the nested data
}
type Profile {
email: String
}
All allowed options of all available directives are explained by the schema definition.
Use the @upsertOn
directive to filter which events will be used to add or update fields on a projection. It specifies which identifier to use to identify the data.
In the following example the projection will be changed by all events of the types createUser
and updateUser
. The id
payload will be used to identify the data. You can use payload fields that contain arrays of (id-)strings in order to update multiple datasets by one single event.
type User @upsertOn(on: { eventTypes: ["createUser", "updateUser"] }, identifyBy: { payload: ["id"] }) {
...
}
You can also omit the identifyBy
attribute. By doing that the projection will be applied to all existing data:
type User @upsertOn(on: { eventTypes: ["createUser", "updateUser"] }) {
...
}
You can use @upsertOn
multiple times. The projection logic will be executed for every match.
Use the @removeOn
directive to filter which events will be used to remove a projection entry. It specifies which identifier to use to identify the data.
In the following example the projection will remove a projection entry on removeUser
events.
type User @removeOn(on: { eventTypes: ["removeUser"] }, identifyBy: { payload: ["id"] }) {
...
}
You can use @removeOn
multiple times. The remove logic will be executed for every match.
The @expires
directive allows the specification of a condition on which the entry will expire.
When using the @expires
directive, the condition environment will only have the projection
field available for this operation.
type Something @expires(condition: "now > projection.expiresAt") {
expiresAt: DateTime!
...
}
Use the @permission
directive on an object to apply permissions to it. Only users having that required permissions as a scope in their token will see data of it.
type User
@upsertOn(
on: { eventTypes: ["createUser"] }
identifyBy: { payload: ["id"] }
)
@permission(read: [PERMISSION_KEY]) {
...
}
Use the @global
directive on an object to get a projection over all tenants. The data of this projection will be stored as part of the global tenant (""
).
type User
@upsertOn(
on: { eventTypes: ["createUser"] }
identifyBy: { payload: ["id"] }
)
@global {
...
}
You can run a projection by calling an API by using the @webhook
directive on the object level.
The entire projection data will be replaced by the resulting json of the called endpoint.
type User
@upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] })
@webhook(
url: "https://example.com/:path"
method: "GET"
condition: "payload.name == ''"
topics: ["users"]
events: ["createUser"]
path: [{ key: "path", value: "payload.name" }]
) {
field: String!
}
The first @webhook
directive that has a matching condition
will be executed.
url
and path
are normal string parameters.
The condition
is an expression (see @from
directive).
The topics
and events
are arrays (see @from
directive).
path
, query
, header
and body
are arrays of small objects that contain two fields: key
and value
.
The value
field has to be an expression (see @from
directive).
In case of the path
argument the key
is a placeholder that will be replaced by the calculated value (in the example above :path
will be replaced by the name
field of the payload).
In case of the query
argument the key
is the name of a query parameter that will be added along with the calculated value.
In case of the header
argument the key
is the name of a header that will be added along with the calculated value.
In case of the body
argument all key
and value
pairs will be combined to a key cvalue map and send as json in the body of the request.
The result from the webhook must contain a json structure. The directive will take the value
field from it and write the value of it into the projection.
You can mark field compounds as unique by using the @unique
directive at type level:
type User
@upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] })
@unique(fields: ["field", "field2"], name: "field and field2 unique") {
field: String!
field2: String!
}
In general fields will be filled by the event payload field that matches the field name.
Example:
Event payload:
{
"id": "some-id",
"firstName": "John",
"lastName": "Doe"
}
Projection schema:
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
lastName: String
}
When the event containing the example payload is thrown, the User
prrojection will have an entry with the id some-id
and data:
{
"lastName": "Doe"
}
Required fields (e.g. String!
): Required fields will default to their zero value (""
for strings, 0
for numbers, false
for booleans).
You can add special annotations to fields to add custom logic:
Use the @identifier
directive on a field to add the projection entry id to that field.
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: ID! @identifier
}
Use the @changedAt
directive on a field to add the time on which the projection entry was last changed to that field.
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: String! @changedAt
}
Use the @createdAt
directive on a field to add the time on which the projection entry was created to that field.
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: String! @createdAt
}
Use the @uuidv4
directive on a field to mark it as having to contain a valid UUIDv4.
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: ID! @uuidv4
}
In addition to that the generate
argument can be used to automaticly generate a valid UUIDv4, if the projection entry is empty on that field.
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: ID! @uuidv4(generate: true)
}
Use the @validate
directive on a field to add validation against a rule tag from the go-playground/validator package.
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: String! @validate(tags: ["email"])
}
Use the @default
directive on a field to define its default value if the projection entry is empty on that field.
The type can be either a non-null type or a nullable type. On a non-null type the respective zero type is replaced by the default value, whilst on the nullable type the null value is replaced by the default.
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: String! @default(value: "John Appleseed")
}
Use the @index
directive on a field to enable filtering on it in all list queries and graphql subscriptions.
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: String! @index
}
Use the @filterFromJwtData
directive on a field to filter it automatically by the given value from the jwt claims in all queries and graphql subscriptions.
The value that is used for the filter will be extracted from the jwt claims data object's field that is identified by the given key
(in this example this would be data.yourJwtDataKey
).
Note: If the filter value from the jwt contains array data, the filter will check if that field matchesthe first element of that array. If the value from the jwt is null or empty no filter will be applied.
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: String! @filterFromJwtData(key: "yourJwtDataKey")
}
Use the @permission
directive on a field to apply permissions to it. Only users having that required permissions as a scope in their token will see that field.
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: String! @permission(read: [PERMISSION_KEY])
}
Use the @unique
directive on a field to mark it as unique.
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: String! @unique
}
Use the @from
directive on a field to project data from a specified field to this field.
Because of the rather complex nature that are projections rules, one has access to a simple expression language called expr. The directive then allows to specify a value and a condition expression. However whilst the value does not have to, the condition must evaluate to a boolean value.
the from directive has access to the following variables:
metatata
: Event metadata. Possible fields:metadata.id
: The id of the event (string)metadata.tenantId
: The tenant id of the event (string)metadata.stream
: The stream name of the event (string)metadata.type
: The type name of the event (string)metadata.correlationId
: The tenant correlation id of the event (string)metadata.causationId
: The causation id of the event (string)metadata.reason
: The reason string of the event (string)metadata.topic
: The topic of the event (string)metadata.raisedAt
: The time when this event was raised, represented as unix timestamp (milliseconds) (int64)
payload
- all fields that the event payload contains
- if your payload field is an object (not a reference to an other projection), you can access all object fields, too:
payload.user.name
would access the name field of the user object in the user field of the event payload
projection
- all fields that the event projection contains
- the
projection
variable contains the state of the projection before the event is applied to the projection and will therefore be empty for the first event that generates a new projection data entry - if your projection field is an object (not a reference to an other projection), you can access all object fields, too:
payload.user.name
would access the name field of the user object in the user field of the projection
In additon to the functions build into expr projections provides the following functions:
append(array, value)
: appends thevalue
to thearray
intSum(arrayOfInts)
: calculates the sum of all elements in thearrayOfInts
(requires actualint
values in the array, maybe make use ofmap(arrayOfNumbers, {int(#)})
to convert all values to integers)
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: String! @from(value: "payload.id")
}
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: String! @from(value: "payload.id", condition: "metadata.stream == 'users'")
}
In addition to that the behviour can only be triggered on a specified set of events using the events
agrument.
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: String! @from(events: ["UserCreated"], value: "payload.id")
}
You can access already projected data by projection.YOUR_FIELD_NAME
. If the projection logix is running the first time for a given identifier, the data in projection
will be empty. Therefore projection.YOUR_FIELD_NAME == nil
will evaluate to true
.
You can get data from an API into a field by using the @webhook
directive.
type User @upsertOn(on: { eventTypes: ["createUser"] }, identifyBy: { payload: ["id"] }) {
field: String!
@webhook(
url: "https://example.com/:path"
method: "GET"
condition: "payload.name == ''"
path: [
{ key: "path", value: { value: "payload.name", condition: "payload.name == ''" } }
]
)
}
The first @webhook
directive that has a matching condition
will be executed.
url
and path
are normal string parameters. The condition
is an expression (see @from
directive). path
, query
, header
and body
are arrays of small objects that contain two fields: key
and value
. The value
is similar to the @from
directive.
In case of the path
argument the key
is a placeholder that will be replaced by the calculated value (in the example above :path
will be replaced by the name
field of the payload).
In case of the query
argument the key
is the name of a query parameter that will be added along with the calculated value.
In case of the header
argument the key
is the name of a header that will be added along with the calculated value.
In case of the body
argument all key
and value
pairs will be combined to a key cvalue map and send as json in the body of the request.
The result from the webhook must contain a json structure. The directive will take the value
field from it and write the value of it into the field.