A cruddl project consists of one or multiple GraphQL schema files and, optionally, metadata files in JSON or YAML format. A simple example can look like this:
import { Project } from 'cruddl';
const project = new Project([
{
name: 'schema.graphqls',
body: `
type Order @rootEntity {
orderNumber: String
}
`,
},
{
name: 'permission-profiles.json',
body: JSON.stringify({
permissionProfiles: {
default: {
permissions: [
{
roles: ['users'],
access: 'readWrite',
},
],
},
},
}),
},
]);
The file schema.graphqls
contains type definitions while permission-profiles.json
provides
metadata (in this case, to grant unrestricted access users with the "users" role). The two file
formats are distinguished by the extension of the name
.
In this example, we define one type Order
with a scalar field. This project already allows to
create, update, delete and read Order
objects. See the api documentation on how the
queries and mutations look like.
In the example above, Order
is decorated with the @rootEntity
directive, which distinguishes it
from the other kinds of type definitions: @childEntity
, @entityExtension
and @valueObject
. We
use this to map the features of a document database to the world of GraphQL.
Root entity types are the equivalent of documents in a document database. They have an implicit,
auto-generated id
field that identifies a root entity uniquely and is used to update and delete
existing root entities. All queries and mutations start on root entities - all other kinds of
objects are only accessible via their parent root entity.
type Order @rootEntity {
orderNumber: String
}
When updating root entities and you specify only a subset of its fields, the other fields are not touched.
Child entities behave like collections within root entities. They also have an auto-generated id
field and it is possible to create, update and delete individual child entities. In contrast to root
entities, child entities are embedded within a root entity, another child entity or an entity
extension. A @childEntity
type can also only be used within a list type.
type OrderItem @childEntity {
itemNumber: String
quantity: Int
}
type Order @rootEntity {
# ...
items: [OrderItem]
}
As with root entities, if you omit fields when updating a child entity, those are kept as-is.
Child entity types can only be used within list types. To group a set of fields into a single object within an entity, use entity extensions.
type PaymentInfo @entityExtension {
creditCardNumber: String
payPalToken: String
}
type Order @rootEntity {
# ...
paymentInfo: PaymentInfo
}
Entity extension can be used within root entities, child entities and other entity extensions. If
you omit fields when updating an entity extension, those are kept as-is. Entity extensions are never
null
- if you omit it entirely, or an object was created before it was added, the field evaluates
to an empty object.
Value objects are treated as atomic values much like scalars are. They cannot be partially updated
but are replaced completely on updates. If you omit fields on update, they are set to null
. This
is useful for types like addresses. They can also be used within lists.
type Address @valueObject {
street: String
postalCode: String
city: String
}
type Order @rootEntity {
# ...
shippingAddress: Address
}
Value object types are the most basic kinds of types and can only have fields of scalar, enum or value object type.
Relations define links between root entities. Use the @relation
directive to define the forward
link and @relation(inverseOf: "otherFieldName")
for the back link.
type Order @rootEntity {
# ...
customer: Customer @relation
}
type Customer @rootEntity {
name: String
orders: [Order] @relation(inverseOf: "customer")
}
The back link can be omitted. If you however only omit the inverseOf
argument on the back link,
this creates two independent relations - one from customers to orders, and one from orders to
customers. The cardinality of relations is determined by the field types. In this case, customer
is of type Customer
, but the inverse field - orders
has a list type, resulting in a n-to-1
relation from orders to customers.
In ArangoDB, relations are stored in an edge collection with the name orders_customer
(the plural
of the source object combined with the field name).
When a root entity is deleted, all related edges are deleted as well, but the related objects are
left untouched. Forward relations (@relation
without an inverseOf
argument) can specify the
onDelete
argument to change this default behavior.
If onDelete: RESTRICT
is configured, deletion is prevented if there are still objects linked with
this relation. The related objects or the relations need to be deleted before the object can be
deleted. This can either be done in its own operation or as a separate mutation field in the same
operation that comes before the delete mutation field. On self-recursive relations (e.g. to child
objects of the same type), you can also specify the parent and child ids in one deleteObjects
or
deleteAllObjects
mutation.
If onDelete: CASCADE
is configured, objects in this relation will be deleted alongside the object
itself. If the related object has relations with onDelete: CASCADE
, those will be deleted as well.
This mode cannot be used recursively; it is a validation error in the model if a recursion is
detected. It also can only be used on 1-to-n and m-to-n relations.
References allow to link to a root entity from any field, not only from another root entity. In contrast to relations, it is however not possible to navigate from the referenced object to the referencing object.
References are built from regular scalar fields that hold key, e.g. countryISOCode
of type
String
. To upgrade this to a reference, you need to define a referenced root entity with a
dedicated key field (e.g. Country.isoCode
), and then use @reference
to link them:
type Country @rootEntity {
isoCode: String @key
name: String
}
type Address @valueObject {
# ...
countryISOCode: String
country: Country @reference(keyField: "countryISOCode")
}
In the data base, only countryISOCode
will be stored, and you can use the field in normal way
(setting, updating, filtering, sorting querying), In addition, you will be able to query the field
country
:
{
Company(name: "AEB") {
address {
countryISOCode
country {
isoCode
name
}
}
}
}
The referenced country will be looked on demand. If the referenced object does not exist, it will be
null
( though countryISOCode
will still result in its value). You can think of references as
more of an API feature than a modelling feature.
You can omit the argument keyField
on the @reference
directive. In the database, it will be
stored with the name of the reference field. This variant is deprecated and should no longer be
used.
With the @collect
directive, you can define fields that are not persisted but rather compute their
value when queried, based on other fields. It allows you to follow a path of relations, child
entities and other fields, collect these values and optionally apply aggregations on them.
You can use @collect
to follow two relations and collect all inner entities:
type OrderItem @childEntity {
itemNumber: String
}
type Order @rootEntity {
items: [OrderItem]
}
type Shipment @rootEntity {
orders: [Order] @relation
allItems: [OrderItem] @collect(path: "orders.items")
}
The field allItems
will return all items in all orders of a shipment. It will not be available for
filtering or sorting and you will not be able to set it directly in create and update mutations.
The path can traverse an arbitrary number of fields. Only the objects of the last field will be
returned, and the type of that last field needs to match the traversal field type (OrderItem
in
the example). References cannot yet be followed, but you can use other traversal fields in the path.
If you have a root entity with a relation to itself, you can use a collect field to flatten the tree:
type HandlingUnit {
childHandlingUnits: [HandlingUnit] @relation
parentHandlingUnit: HandlingUnit @relation(inverseOf: "childHandlingUnits")
allInnerHandlingUnits: [HandlingUnit] @collect(path: "childHandlingUnits{1,3}")
}
The field allInnerHandlingUnits
will result in the direct children, their children, and their
children (by default, in depth-first order). The first number (1
) is the minimum depth (which can
also be 0
to include the originating entity), and the second number (3
) is the maximum depth. If
you omit the maximum depth, the minimum depth will be used as maximum depth. It's not possible to
entirely omit the maximum depth.
The minimum and maximum depth can only be specified on directly recursive relations. It is not possible to cycle through indirectly recursive relations, and child entities don't support this feature at all.
If you follow a field that can be null (e.g. a to-1 relation or a simple scalar field), the
collection may include null
values. However, it is not allowed to define a list field that could
include null
values. Therefore, you need to define an aggregation, e.g. DISTINCT
to remove null
values.
If you follow a list field on an object that is null (e.g. order.orderItems
if order
is null),
this null object just won't contribute any items. The resulting list will not include null
in this
case.
A collect path can also end in a scalar field. This however requires the use of an aggregator (see
next section). Use the aggregator DISTINCT
if you are interested in the individual field values
(see next section).
With the optional aggregate
argument, you can perform an aggregation on all collected items. For
example, this allows you to sum up numbers:
type OrderItem @childEntity {
itemNumber: String
quantity: Int
}
type Order @rootEntity {
items: [OrderItem]
totalQuantity: Int @collect(path: "items.quantity", aggregate: SUM)
}
The path can use all the features from above and also use other @collect
fields (but not nested
aggregations at the moment).
The following operators are supported:
Operator | Description | Supported Types | Null values | Result on empty list |
---|---|---|---|---|
COUNT |
Total number of items (including null ) |
all types (last segment must be a list) | included | 0 |
SOME |
true if there are any items (including null ) |
all types (last segment must be a list) | included | false |
NONE |
true if the list is empty |
all types (last segment must be a list) | included | true |
None | ||||
COUNT_NULL |
Number of items that are null |
all nullable types | see description | 0 |
COUNT_NOT_NULL |
Number of items that are not null |
all nullable types | see description | 0 |
SOME_NULL |
true if there are items that are null |
all nullable types | see description | false |
SOME_NOT_NULL |
true if there are items that are not null |
all nullable types | see description | false |
EVERY_NULL |
true if there are no items that are not null |
all nullable types | see description | true |
NONE_NULL |
true if there are no items that are null |
all nullable types | see description | true |
Numbers | ||||
MIN |
Minimum value (ignoring null ) |
Int , Float , DateTime , LocalDate , LocalTime |
excluded | null |
MAX |
Maximum value (ignoring null ) |
Int , Float , DateTime , LocalDate , LocalTime |
excluded | null |
SUM |
Sum (ignoring null ) |
Int , Float |
excluded | 0 |
AVERAGE |
Sum / Count (ignoring null ) |
Int , Float |
excluded | null |
Boolean | ||||
COUNT_TRUE |
Number of items that are true |
Boolean |
≙ false |
0 |
COUNT_NOT_TRUE |
Number of items that are not true |
Boolean |
≙ false |
0 |
SOME_TRUE |
true if there are items that are true |
Boolean |
≙ false |
false |
SOME_NOT_TRUE |
true if there are items that are not true |
Boolean |
≙ false |
false |
EVERY_TRUE |
true if there are no items that are not true |
Boolean |
≙ false |
true |
NONE_TRUE |
true if there are no items that are true |
Boolean |
≙ false |
true |
Distinct | ||||
DISTINCT |
all non-null values without duplicates | String , ID , child/root entities, and enums |
excluded | [] |
COUNT_DISTINCT |
number of non-null values without duplicates | String , ID , child/root entities, and enums |
excluded | 0 |
Note that if a value is collected multiple times, it will be used multiple times by the aggregator
(e.g. counted twice).In the future, it will be possible to a field with DISTINCT
aggregation in
another aggregation field. Note that if a value is collected multiple times, it will be used
multiple times by the aggregator (e.g. counted twice). In the future, it will be possible to a field
with DISTINCT
aggregation in another aggregation field.
- Reference fields can currently not be used in the collect path.
- Aggregation fields can currently not be used in the collect path, including the
DISTINCT
operator. - Min/Max depth can currently not be specified on child entities or nested value objects.
- Min/Max depth can currently not be specified for indirectly recursive relations.
- The InMemoryAdapter currently does not support min/max depth.
Also, some possible performance optimizations are not implemented yet. For example, the DISTINCT
operator is applied at the end of a collection and not as early as possible.
cruddl provides a role-based permission system. Permission rules can be defined on root entities and on fields. For root entities, the permissions can also be made dependent on a special field within the object to implement multi-tenancy.
Currently, there are two ways to specify permissions: Via the @roles
directive or via permission
profiles. The @roles
directive will be deprecated in the future, but currently it is needed for
field-based permissions.
The basic example above already specified a permission profile default
which is applied to all
root entities without an explicit permissionProfile
argument in the @rootEntity
directive:
{
"permissionProfiles": {
"default": {
"permissions": [
{
"roles": ["users"],
"access": "readWrite"
}
]
}
}
}
This profile grants unrestricted access for the "users" role. The roles of a user are take from the
authContext
property on the GraphQL context. The following permission profile "restricted" allows
read access to all roles starting with "user" and provides full access to the role "admin":
{
"permissionProfiles": {
"restricted": {
"permissions": [
{
"roles": ["admin"],
"access": "readWrite"
},
{
"roles": ["user*"],
"access": "read"
}
]
}
}
}
You can use this profile as follows:
type Order @rootEntity(permissionProfile: "restricted") {
# ...
}
Permission profiles are looked up in all json/yaml files within the type's namespace. If not found
there, the namespace tree is navigated upwards. Permission profiles can be shadowed (i.e. a profile
can be defined in a parent and in a child namespace, and the child namespace wins), but this
generates a warning. The permission profile default
can be shadowed without warning, so you can
have namespace-dependent default permission profiles.
If a role specifier in a permission profile starts with a forward slash (/
), it is interpreted as
a regular expression. Be careful to use the start-of-string and end-of-string anchors (^
and $
)
to match the whole role instead of just part of it. An example would be "/^supplier-([a-z]+)$/"
.
To set permissions on individual fields, you need to use the @roles
directive. This will be
changed in the future to also work with permission profiles.
type Customer @rootEntity {
paymentInfo: PaymentInfo @roles(readWrite: ["support-privileged"], read: [])
}
Use restrictToAccessGroups
for a data-dependent permission profile: grants unrestricted access for
all roles. The following permission profile "restricted" allows read access to all roles starting
with "user" and provides full access to the role "admin":
{
"permissionProfiles": {
"restricted": {
"permissions": [
{
"roles": ["admin"],
"access": "readWrite"
},
{
"roles": ["support-europe"],
"access": "read",
"restrictToAccessGroups": ["EUROPE"]
},
{
"roles": ["support-america"],
"access": "read",
"restrictToAccessGroups": ["NORTH_AMERICA", "SOUTH_AMERICA"]
}
]
}
}
}
You can use this profile as follows:
type Order @rootEntity(permissionProfile: "restricted") {
# ...
accessGroup: OrderAccessGroup
}
enum OrderAccessGroup {
EUROPE
NORTH_AMERICA
SOUTH_AMERICA
RESTRICTED
}
- Users with role "admin" can access all orders.
- Users with role "support-europe" can only access orders where
accessGroup
has the value "EUROPE". - Users with role "support-america" can only access orders where
accessGroup
has the value "NORTH_AMERICA" or " SOUTH_AMERICA". - Users with role "support-america" and "support-europe" can only access orders where
accessGroup
has the value " EUROPE", "NORTH_AMERICA", or "SOUTH_AMERICA".
You can also dynamically assign role-dependent access groups using regular expressions and capturing groups. For example:
{
"permissionProfiles": {
"forwarders": {
"roles": ["/^forwarder-(.+)$/"],
"access": "readWrite",
"restrictToAccessGroups": ["forwarded-by-$1", "forwarded-by-anyone"]
}
}
}
A user with roles forwarder-fast
and forwarder-quick
is granted access to objects with
accessGroup
forwarded-by-fast
, and forwarded-by-quick
, and forwarded-by-anyone
.
If you need to restrict access depending on multiple fields, or you want to use a custom field
instead of accessGroup
, you can alternatively use the following more flexible feature.
{
"permissionProfiles": {
"restricted": {
"permissions": [
{
"roles": ["support"],
"access": "readWrite",
"restrictions": [
{
"field": "isTopSecret",
"value": false
}
]
},
{
"roles": ["/^seller-(.+)$/"],
"access": "readWrite",
"restrictions": [
{
"field": "seller",
"valueTemplate": "$1"
}
]
},
{
"roles": ["/^buyer-(.+)$/"],
"access": "readWrite",
"restrictions": [
{
"field": "buyer",
"valueTemplate": "$1"
}
]
}
]
}
}
}
Without restrictions
or with an empty array, the role grants access to all root entities covered
by the permission profile. Each item in the restrictions
array applies a further restriction on
the value of a field. Only if all restrictions are met in a root entity, the access will be granted.
If you need an OR combination, use multiple permissions
items with identical roles.
Each restriction applies to a single field. You can specify a dot-separated path to nested fields, but root entity boundaries cannot be crossed. In general, the restrictions of index field paths apply, too.
The simplest kind of restriction requires a specific field value ("value": "theValue"
). If you
used a regular expression for matching the roles, you can also spedify valueTemplate
and use the
capture groups, e.g. "valueTemplate": "value-$1"
.
If you supply the claims of a user's token, you can also restrict access to objects where the
field's value equals this claim value (or any of the claim values, if the claim is an array), with
"claim": "claimName"
.
cruddl supports index handling. During start up they are extracted from the schema and created or removed from the database.
Indices can be added to the @rootEntity
directive:
type Order @rootEntity(indices: [{ fields: ["orderNumber"], unique: true}, { fields: [ "shippingAddress.country", "shippingAddress.postalCode"] }]) {
# ...
}
Indices can also be directly attached to fields. Multi-field indices are not supported via field definition.
type Order @rootEntity {
orderNumber: String @unique
trackingNumber: String @index
# ...
}
Aside from the field list, indices have two options: unique
and sparse
. The field directives
only have the sparse
option because @unique
is its own directive.
-
If
unique
is set, a unique constraint is created. This constraint ensures that there are no two root entities with the same value for this field. The index cannot be created (the creation will fail) if there are already conflicting root entities, and following creations and updates will throw an error if the constraint would be violated through this change. -
sparse
determines whethernull
values will be included in the index. If the option is set totrue
,null
values will be omitted. Regular indices are non-sparse by default, unique indices are sparse by default. Unique sparse indices allow the valuenull
in at most one root entity. For non-unique indices, thesparse
option reduces index size, but it disqualifies the index to be used in some cases. For example, a sparse index cannot be used for sorting unless there also is a filter that makes sure nonull
values can be returned (even if there are actually nonull
values currently in the database).
There are two additional directives you can use on fields: @defaultValue
and @calcMutations
.
When an object is created without specifying all fields, the unspecified fields will be set to
null
(scalars and value objects), {}
(entity extensions), or []
(child entities).
You can add the @defaultValue
directive to a field to change this behavior. The default value
will be used during object creation if the field value is not specified. The directive does not
affect existing objects. This still applies if you add a new field with the @defaultValue
directive: only newly created objects will have the default value.
Be careful with specifying the field value exactly, because there are currently no typechecks for
default values. For example, for fields of type Int
, make sure you specify a number and not a
string value:
type Delivery @rootEntity {
isShipped: Boolean @defaultValue(value: false)
}
If you add the @calcMutations
directive to a scalar field, additional input fields will be
available for the update mutations. These fields perform simple calculations like incrementing a
value or appending a list item. Calc mutations are always executed within the transaction, so you
can use this to e.g. update a number range without risk of race conditions.
For example, given the following type definition:
type NumberRange @rootEntity {
name: String @key
value: Int @calcMutations(operators: [ADD])
}
You can increment the value
with a mutation like this:
mutation UpdateNumberRange {
updateNumberRange(input: { name: "deliveries", value_add: 1 }) {
value
}
}
The following operators are available on numeric types (GraphQLInt
, GraphQLInt53
,
GraphQLFloat
, GraphQLDecimal1
, GraphQLDecimal2
and GraphQLDecimal3
);
MULTIPLY
DIVIDE
ADD
SUBTRACT
MODULO
The following operators are available on the type String
:
APPEND
PREPEND
The String
scalar type represents textual data, represented as UTF-8 character sequences. The
String type is most often used by GraphQL to represent free-form human-readable text.
The ID
scalar type represents a unique identifier, often used to refetch an object or as key for a
cache. The ID type appears in a JSON response as a String; however, it is not intended to be
human-readable. When expected as an input type, any string (such as "4"
) or integer (such as 4
)
input value will be accepted as an ID.
The I18nString
scalar type represents an internationalized string.
Structurally, the I18nString
type is equivalent to the StringMap
type. Keys are ISO 639-1
language codes, and values are the localized strings. In the future, more specific features may be
added to this type, so it is preferred over the "StringMap" type to represent internationalized
strings.
Values are not additionally JSON-encoded or JSON-parsed, so e.g. pass a raw JSON object here instead of a JSON-representation of that object.
The Int
scalar type represents non-fractional signed whole numeric values. Int can represent
values between -(2^31)and 2^31 - 1.
The Float
scalar type represents signed double-precision fractional values as specified by
IEEE 754.
The Int53
scalar type represents non-fractional signed whole numeric values. Int53
can represent
values between -( 2^53) and 2^53 - 1.
Values of this type are serialized as numbers in GraphQL and JSON representations. The numeric range of this type corresponds to the safe integer range of an IEEE 754 double precision binary floating-point value.
The Decimal1
, Decimal2
, and Decimal3
scalar types represent signed numeric values with up to
1, 2, or 3, respectively, decimal digits. All three types can represent values between -1000000000.0
and 1000000000.0.
Values of this type are serialized as numbers in GraphQL and JSON representations. The value is always rounded to the respective amount of decimal digit.
The DateTime
scalar type represents a point in time in UTC, in a format specified by ISO 8601,
such as 2007-12-03T10:15:30Z
or 2007-12-03T10:15:30.123Z
.
This scalar type rejects values without timezone specifier or with a timezone other than UTC. See
also OffsetDateTime
for a point in time with an explicit timezone offset, or LocalDate
and
LocalTime
for values without timezone specifier.
The second part is added if not specified, e.g. 2007-12-03T12:34Z
is converted to
2007-12-03T12:34:00Z
. Second fraction digits are cut off at the nearest three-digit group, e.g.
2007-12-03T00:00:00.1234Z
is converted to 2007-12-03T00:00:00.123400Z
.
Values with leap seconds are shifted back by one second, but this behavior should not be relied upon.
The LocalDate
scalar type represents a date without time zone in a format specified by ISO 8601,
such as 2007-12-03.
The LocalTime
scalar type represents a time without time zone in a format specified by ISO 8601,
such as 10:15:30 or 17:05:03.521.
The valid range is between 00:00:00 and 23:59:59.999999999. 24:00 is not allowed to avoid bugs in clients that treat 24: 00 as 0:00.
The seconds part is cut off if it is zero, e.g. 12:34:00 is converted to 12:34. Second fraction digits are cut off at the nearest three-digit group, e.g. 00:00:00.1234 is converted to 00:00:00.123400.
Leap seconds can not be specified.
The OffsetDateTime
scalar type represents a point in time with a timezone offset, in a format
specified by ISO 8601, such as 2007-12-03T10:15:30+01:00
or 2007-12-03T10:15:30.123Z
.
Only use this type for timestamps that are inherently tied to a location and the timezone offset
should be calculated eagerly. To only store a point in time, use DateTime
.
The second part is added if not specified, e.g. 2007-12-03T12:34Z
is converted to
2007-12-03T12:34:00Z
. Offset specifier Z
is accepted but will be converted to +00:00
. Leap
seconds are not supported.
The Boolean
scalar type represents true
or false
.
The JSON
scalar type represents an arbitrary JSON value. This can be a string, number, boolean,
array, or object.
Values are not additionally JSON-encoded or JSON-parsed, so e.g. pass a raw JSON object here instead of a JSON-representation of that object.
The JSONObject
scalar type represents a JSON object type with arbitrary properties.
This is similar to the JSON
scalar type but disallows arrays, strings, numbers, and booleans.
Values are not additionally JSON-encoded or JSON-parsed, so e.g. pass a raw JSON object here instead of a JSON-representation of that object.
The StringMap
scalar type consists of a JSON object with only strings as values.
This type can be used for key-value mappings where fetching keys without values or values without keys does not make sense. For arbitrary maps, the "JSONObject" type can be used instead.
Values are not additionally JSON-encoded or JSON-parsed, so e.g. pass a raw JSON object here instead of a JSON-representation of that object.
Root entities and child entities have the implicit fields id
, createdAt
and updatedAt
(the
latter two of type DateTime
). They are managed by cruddl and cannot be overwritten.