With ArangoTemplate
Spring Data ArangoDB offers a central support for interactions with the database over a rich feature set. It mostly offers the features from the ArangoDB Java driver with additional exception translation from the drivers exceptions to the Spring Data access exceptions inheriting the DataAccessException
class.
The ArangoTemplate
class is the default implementation of the operations interface ArangoOperations
which developers of Spring Data are encouraged to code against.
Spring Data Commons provides a composable repository infrastructure which Spring Data ArangoDB is built on. These allow for interface-based composition of repositories consisting of provided default implementations for certain interfaces (like CrudRepository
) and custom implementations for other methods.
Instances of a Repository are created in Spring beans through the auto-wired mechanism of Spring.
public class MySpringBean {
@Autowired
private MyRepository rep;
}
The method return type for single results can be a primitive type, a domain class, Map<String, Object>
, BaseDocument
, BaseEdgeDocument
, Optional<Type>
, GeoResult<Type>
.
The method return type for multiple results can additionally be ArangoCursor<Type>
, Iterable<Type>
, Collection<Type>
, List<Type>
, Set<Type>
, Page<Type>
, Slice<Type>
, GeoPage<Type>
, GeoResults<Type>
where Type can be everything a single result can be.
Queries using ArangoDB Query Language (AQL) can be supplied with the @Query
annotation on methods. AqlQueryOptions
can also be passed to the driver, as an argument anywhere in the method signature.
There are three ways of passing bind parameters to the query in the query annotation.
Using number matching, arguments will be substituted into the query in the order they are passed to the query method.
public interface MyRepository extends Repository<Customer, String>{
@Query("FOR c IN customers FILTER c.name == @0 AND c.surname == @2 RETURN c")
ArangoCursor<Customer> query(String name, AqlQueryOptions options, String surname);
}
With the @Param
annotation, the argument will be placed in the query at the place corresponding to the value passed to the @Param
annotation.
public interface MyRepository extends Repository<Customer, String>{
@Query("FOR c IN customers FILTER c.name == @name AND c.surname == @surname RETURN c")
ArangoCursor<Customer> query(@Param("name") String name, @Param("surname") String surname);
}
In addition you can use a parameter of type Map<String, Object>
annotated with @BindVars
as your bind parameters. You can then fill the map with any parameter used in the query. (see here for more Information about Bind Parameters).
public interface MyRepository extends Repository<Customer, String>{
@Query("FOR c IN customers FILTER c.name == @name AND c.surname = @surname RETURN c")
ArangoCursor<Customer> query(@BindVars Map<String, Object> bindVars);
}
A mixture of any of these methods can be used. Parameters with the same name from an @Param
annotation will override those in the bindVars
.
public interface MyRepository extends Repository<Customer, String>{
@Query("FOR c IN customers FILTER c.name == @name AND c.surname = @surname RETURN c")
ArangoCursor<Customer> query(@BindVars Map<String, Object> bindVars, @Param("name") String name);
}
An alternative to using the @Query
annotation on methods is specifying them in a separate .properties
file. The default path for the file is META-INF/arango-named-queries.properties
and can be changed with the EnableArangoRepositories#namedQueriesLocation()
setting. The entries in the properties file must adhere to the following convention: {simple entity name}.{method name} = {query}
. Let’s assume we have the following repository interface:
package com.arangodb.repository;
public interface CustomerRepository extends ArangoRepository<Customer> {
Customer findByUsername(@Param("username") String username);
}
The corresponding arango-named-queries.properties
file looks like this:
Customer.findByUsername = FOR c IN customers FILTER c.username == @username RETURN c
The queries specified in the properties file are no different than the queries that can be defined with the @Query
annotation. The only difference is that the queries are in one place. If there is a @Query
annotation present and a named query defined, the query in the @Query
annotation takes precedence.
Spring Data ArangoDB supports queries derived from methods names by splitting it into its semantic parts and converting into AQL. The mechanism strips the prefixes find..By
, get..By
, query..By
, read..By
, stream..By
, count..By
, exists..By
, delete..By
, remove..By
from the method and parses the rest. The By acts as a separator to indicate the start of the criteria for the query to be built. You can define conditions on entity properties and concatenate them with And
and Or
.
The complete list of part types for derived methods is below, where doc is a document in the database
Keyword | Sample | Predicate |
---|---|---|
IsGreaterThan, GreaterThan, After | findByAgeGreaterThan(int age) | doc.age > age |
IsGreaterThanEqual, GreaterThanEqual | findByAgeIsGreaterThanEqual(int age) | doc.age >= age |
IsLessThan, LessThan, Before | findByAgeIsLessThan(int age) | doc.age < age |
IsLessThanEqualLessThanEqual | findByAgeLessThanEqual(int age) | doc.age <= age |
IsBetween, Between | findByAgeBetween(int lower, int upper) | lower < doc.age < upper |
IsNotNull, NotNull | findByNameNotNull() | doc.name != null |
IsNull, Null | findByNameNull() | doc.name == null |
IsLike, Like | findByNameLike(String name) | doc.name LIKE name |
IsNotLike, NotLike | findByNameNotLike(String name) | NOT(doc.name LIKE name) |
IsStartingWith, StartingWith, StartsWith | findByNameStartsWith(String prefix) | doc.name LIKE prefix |
IsEndingWith, EndingWith, EndsWith | findByNameEndingWith(String suffix) | doc.name LIKE suffix |
Regex, MatchesRegex, Matches | findByNameRegex(String pattern) | REGEX_TEST(doc.name, name, ignoreCase) |
(No Keyword) | findByFirstName(String name) | doc.name == name |
IsTrue, True | findByActiveTrue() | doc.active == true |
IsFalse, False | findByActiveFalse() | doc.active == false |
Is, Equals | findByAgeEquals(int age) | doc.age == age |
IsNot, Not | findByAgeNot(int age) | doc.age != age |
IsIn, In | findByNameIn(String[] names) | doc.name IN names |
IsNotIn, NotIn | findByNameIsNotIn(String[] names) | doc.name NOT IN names |
IsContaining, Containing, Contains | findByFriendsContaining(String name) | name IN doc.friends |
IsNotContaining, NotContaining, NotContains | findByFriendsNotContains(String name) | name NOT IN doc.friends |
Exists | findByFriendNameExists() | HAS(doc.friend, name) |
public interface MyRepository extends Repository<Customer, String> {
// FOR c IN customers FILTER c.name == @0 RETURN c
ArangoCursor<Customer> findByName(String name);
ArangoCursor<Customer> getByName(String name);
// FOR c IN customers
// FILTER c.name == @0 && c.age == @1
// RETURN c
ArangoCursor<Customer> findByNameAndAge(String name, int age);
// FOR c IN customers
// FILTER c.name == @0 || c.age == @1
// RETURN c
ArangoCursor<Customer> findByNameOrAge(String name, int age);
}
You can apply sorting for one or multiple sort criteria by appending OrderBy
to the method and Asc
or Desc
for the directions.
public interface MyRepository extends Repository<Customer, String> {
// FOR c IN customers
// FILTER c.name == @0
// SORT c.age DESC RETURN c
ArangoCursor<Customer> getByNameOrderByAgeDesc(String name);
// FOR c IN customers
// FILTER c.name = @0
// SORT c.name ASC, c.age DESC RETURN c
ArangoCursor<Customer> findByNameOrderByNameAscAgeDesc(String name);
}
Geospatial queries are a subsection of derived queries. To use a geospatial query on a collection, a geo index must exist on that collection. A geo index can be created on a field which is a two element array, corresponding to latitude and longitude coordinates.
As a subsection of derived queries, geospatial queries support all the same return types, but also support the three return types GeoPage, GeoResult and Georesults
. These types must be used in order to get the distance of each document as generated by the query.
There are two kinds of geospatial query, Near and Within. Near sorts documents by distance from the given point, while within both sorts and filters documents, returning those within the given distance range or shape.
public interface MyRepository extends Repository<City, String> {
GeoResult<City> getByLocationNear(Point point);
GeoResults<City> findByLocationWithinOrLocationWithin(Box box, Polygon polygon);
//Equivalent queries
GeoResults<City> findByLocationWithinOrLocationWithin(Point point, int distance);
GeoResults<City> findByLocationWithinOrLocationWithin(Point point, Distance distance);
GeoResults<City> findByLocationWithinOrLocationWithin(Circle circle);
}
Property expressions can refer only to direct and nested properties of the managed domain class. The algorithm checks the domain class for the entire expression as the property. If the check fails, the algorithm splits up the expression at the camel case parts from the right and tries to find the corresponding property.
@Document("customers")
public class Customer {
private Address address;
}
public class Address {
private ZipCode zipCode;
}
public interface MyRepository extends Repository<Customer, String> {
// 1. step: search domain class for a property "addressZipCode"
// 2. step: search domain class for "addressZip.code"
// 3. step: search domain class for "address.zipCode"
ArangoCursor<Customer> findByAddressZipCode(ZipCode zipCode);
}
It is possible for the algorithm to select the wrong property if the domain class also has a property which matches the first split of the expression. To resolve this ambiguity you can use _ as a separator inside your method-name to define traversal points.
@Document("customers")
public class Customer {
private Address address;
private AddressZip addressZip;
}
public class Address {
private ZipCode zipCode;
}
public class AddressZip {
private String code;
}
public interface MyRepository extends Repository<Customer, String> {
// 1. step: search domain class for a property "addressZipCode"
// 2. step: search domain class for "addressZip.code"
// creates query with "x.addressZip.code"
ArangoCursor<Customer> findByAddressZipCode(ZipCode zipCode);
// 1. step: search domain class for a property "addressZipCode"
// 2. step: search domain class for "addressZip.code"
// 3. step: search domain class for "address.zipCode"
// creates query with "x.address.zipCode"
ArangoCursor<Customer> findByAddress_ZipCode(ZipCode zipCode);
}
AQL supports the usage of bind parameters which you can define with a method parameter annotated with @BindVars
of type Map<String, Object>
.
public interface MyRepository extends Repository<Customer, String> {
@Query("FOR c IN customers FILTER c[@field] == @value RETURN c")
ArangoCursor<Customer> query(Map<String, Object> bindVars);
}
Map<String, Object> bindVars = new HashMap<String, Object>();
bindVars.put("field", "name");
bindVars.put("value", "john";
// will execute query "FOR c IN customers FILTER c.name == "john" RETURN c"
ArangoCursor<Customer> cursor = myRepo.query(bindVars);
You can set additional options for the query and the created cursor over the class AqlQueryOptions
which you can simply define as a method parameter without a specific name. AqlQuery options can also be defined with the @QueryOptions
annotation, as shown below. AqlQueryOptions from an annotation and those from an argument are merged if both exist, with those in the argument taking precedence.
The AqlQueryOptions
allows you to set the cursor time-to-live, batch-size, caching flag and several other settings. This special parameter works with both query-methods and finder-methods. Keep in mind that some options, like time-to-live, are only effective if the method return type isArangoCursor<T>
or Iterable<T>
.
public interface MyRepository extends Repository<Customer, String> {
@Query("FOR c IN customers FILTER c.name == @0 RETURN c")
Iterable<Customer> query(String name, AqlQueryOptions options);
Iterable<Customer> findByName(String name, AqlQueryOptions options);
@QueryOptions(maxPlans = 1000, ttl = 128)
ArangoCursor<Customer> findByAddressZipCode(ZipCode zipCode);
@Query("FOR c IN customers FILTER c[@field] == @value RETURN c")
@QueryOptions(cache = true, ttl = 128)
ArangoCursor<Customer> query(Map<String, Object> bindVars, AqlQueryOptions options);
}
Spring Data ArangoDB supports Spring Data’s Pageable
and Sort
parameters for repository query methods. If these parameters are used together with a native query, either through @Query
annotation or named queries, a placeholder must be specified:
#pageable
for Pageable
parameter#sort
for Sort
parameterSort properties or paths are attributes separated by dots (e.g. customer.age
). Some rules apply for them:
.customer.age
)customer.`attr.with.dots`
customer.attr_with\`
)customer\
=> customer\\
)just.`some`.`attributes.that`.`form\``.a path\`.\ is converted to
`just`.`some`.`attributes.that`.`form\``.`a path\``.`\\`
Native queries example:
public interface CustomerRepository extends ArangoRepository<Customer> {
@Query("FOR c IN customer FILTER c.name == @1 #pageable RETURN c")
Page<Customer> findByNameNative(Pageable pageable, String name);
@Query("FOR c IN customer FILTER c.name == @1 #sort RETURN c")
List<Customer> findByNameNative(Sort sort, String name);
}
// don't forget to specify the var name of the document
final Pageable page = PageRequest.of(1, 10, Sort.by("c.age"));
repository.findByNameNative(page, "Matt");
final Sort sort = Sort.by(Direction.DESC, "c.age");
repository.findByNameNative(sort, "Tony");
Derived queries example:
public interface CustomerRepository extends ArangoRepository<Customer> {
Page<Customer> findByName(Pageable pageable, String name);
List<Customer> findByName(Sort sort, String name);
}
// no var name is necessary for derived queries
final Pageable page = PageRequest.of(1, 10, Sort.by("age"));
repository.findByName(page, "Matt");
final Sort sort = Sort.by(Direction.DESC, "age");
repository.findByName(sort, "Tony");
In this section we will describe the features and conventions for mapping Java objects to documents and how to override those conventions with annotation based mapping metadata.
@PersistenceConstructor
ArangoDB uses VelocyPack as it’s internal storage format which supports a large number of data types. In addition Spring Data ArangoDB offers - with the underlying Java driver - built-in converters to add additional types to the mapping.
Java type | VelocyPack type |
---|---|
java.lang.String | string |
java.lang.Boolean | bool |
java.lang.Integer | signed int 4 bytes, smallint |
java.lang.Long | signed int 8 bytes, smallint |
java.lang.Short | signed int 2 bytes, smallint |
java.lang.Double | double |
java.lang.Float | double |
java.math.BigInteger | signed int 8 bytes, unsigned int 8 bytes |
java.math.BigDecimal | double |
java.lang.Number | double |
java.lang.Character | string |
java.util.Date | string (date-format ISO 8601) |
java.sql.Date | string (date-format ISO 8601) |
java.sql.Timestamp | string (date-format ISO 8601) |
java.util.UUID | string |
java.lang.byte[] | string (Base64) |
As collections in ArangoDB can contain documents of various types, a mechanism to retrieve the correct Java class is required. The type information of properties declared in a class may not be enough to restore the original class (due to inheritance). If the declared complex type and the actual type do not match, information about the actual type is stored together with the document. This is necessary to restore the correct type when reading from the DB. Consider the following example:
public class Person {
private String name;
private Address homeAddress;
// ...
// getters and setters omitted
}
public class Employee extends Person {
private Address workAddress;
// ...
// getters and setters omitted
}
public class Address {
private final String street;
private final String number;
// ...
public Address(String street, String number) {
this.street = street;
this.number = number;
}
// getters omitted
}
@Document
public class Company {
@Key
private String key;
private Person manager;
// getters and setters omitted
}
Employee manager = new Employee();
manager.setName("Jane Roberts");
manager.setHomeAddress(new Address("Park Avenue", "432/64"));
manager.setWorkAddress(new Address("Main Street", "223"));
Company comp = new Company();
comp.setManager(manager);
The serialized document for the DB looks like this:
{
"manager": {
"name": "Jane Roberts",
"homeAddress": {
"street": "Park Avenue",
"number": "432/64"
},
"workAddress": {
"street": "Main Street",
"number": "223"
},
"_class": "com.arangodb.Employee"
},
"_class": "com.arangodb.Company"
}
Type hints are written for top-level documents (as a collection can contain different document types) as well as for every value if it’s a complex type and a sub-type of the property type declared. Map
s and Collection
s are excluded from type mapping. Without the additional information about the concrete classes used, the document couldn’t be restored in Java. The type information of the manager
property is not enough to determine the Employee
type. The homeAddress
and workAddress
properties have the same actual and defined type, thus no type hint is needed.
By default, the fully qualified class name is stored in the documents as a type hint. A custom type hint can be set with the @TypeAlias("my-alias")
annotation on an entity. Make sure that it is an unique identifier across all entities. If we would add a TypeAlias("employee")
annotation to the Employee
class above, it would be persisted as "_class": "employee"
.
The default type key is _class
and can be changed by overriding the typeKey()
method of the AbstractArangoConfiguration
class.
If you need to further customize the type mapping process, the arangoTypeMapper()
method of the configuration class can be overridden. The included DefaultArangoTypeMapper
can be customized by providing a list of TypeInformationMapper
s that create aliases from types and vice versa.
In order to fully customize the type mapping process you can provide a custom type mapper implementation by extending the DefaultArangoTypeMapper
class.
To deactivate the type mapping process, you can return null
from the typeKey()
method of the AbstractArangoConfiguration
class. No type hints are stored in the documents with this setting. If you make sure that each defined type corresponds to the actual type, you can disable the type mapping, otherwise it can lead to exceptions when reading the entities from the DB.
annotation | level | description |
---|---|---|
@Document | class | marks this class as a candidate for mapping |
@Edge | class | marks this class as a candidate for mapping |
@Id | field | stores the field as the system field _id |
@Key | field | stores the field as the system field _key |
@Rev | field | stores the field as the system field _rev |
@Field(“alt-name”) | field | stores the field with an alternative name |
@Ref | field | stores the _id of the referenced document and not the nested document |
@From | field | stores the _id of the referenced document as the system field _from |
@To | field | stores the _id of the referenced document as the system field _to |
@Relations | field | vertices which are connected over edges |
@Transient | field, method, annotation | marks a field to be transient for the mapping framework, thus the property will not be persisted and not further inspected by the mapping framework |
@PersistenceConstructor | constructor | marks a given constructor - even a package protected one - to use when instantiating the object from the database |
@TypeAlias(“alias”) | class | set a type alias for the class when persisted to the DB |
@HashIndex | class | describes a hash index |
@HashIndexed | field | describes how to index the field |
@SkiplistIndex | class | describes a skiplist index |
@SkiplistIndexed | field | describes how to index the field |
@PersistentIndex | class | describes a persistent index |
@PersistentIndexed | field | describes how to index the field |
@GeoIndex | class | describes a geo index |
@GeoIndexed | field | describes how to index the field |
@FulltextIndex | class | describes a fulltext index |
@FulltextIndexed | field | describes how to index the field |
The annotations @Document
applied to a class marks this class as a candidate for mapping to the database. The most relevant parameter is value
to specify the collection name in the database. The annotation @Document
specifies the collection type to DOCUMENT
.
@Document(value="persons")
public class Person {
...
}
The annotations @Edge
applied to a class marks this class as a candidate for mapping to the database. The most relevant parameter is value
to specify the collection name in the database. The annotation @Edge
specifies the collection type to EDGE
.
@Edge("relations")
public class Relation {
...
}
With the annotation @Ref
applied on a field the nested object isn’t stored as a nested object in the document. The _id
field of the nested object is stored in the document and the nested object has to be stored as a separate document in another collection described in the @Document
annotation of the nested object class. To successfully persist an instance of your object the referencing field has to be null or it’s instance has to provide a field with the annotation @Id
including a valid id.
@Document(value="persons")
public class Person {
@Ref
private Address address;
}
@Document("addresses")
public class Address {
@Id
private String id;
private String country;
private String street;
}
The database representation of Person
in collection persons looks as follow:
{
"_key" : "123",
"_id" : "persons/123",
"address" : "addresses/456"
}
and the representation of Address
in collection addresses:
{
"_key" : "456",
"_id" : "addresses/456",
"country" : "...",
"street" : "..."
}
Without the annotation @Ref
at the field address
, the stored document would look:
{
"_key" : "123",
"_id" : "persons/123",
"address" : {
"country" : "...",
"street" : "..."
}
}
With the annotation @Relations
applied on a collection or array field in a class annotated with @Document
the nested objects are fetched from the database over a graph traversal with your current object as the starting point. The most relevant parameter is edge
. With edge
you define the edge collection - which should be used in the traversal - using the class type. With the parameter depth
you can define the maximal depth for the traversal (default 1) and the parameter direction
defines whether the traversal should follow outgoing or incoming edges (default Direction.ANY).
@Document(value="persons")
public class Person {
@Relations(edge=Relation.class, depth=1, direction=Direction.ANY)
private List<Person> friends;
}
@Edge(name="relations")
public class Relation {
}
With the annotations @From
and @To
applied on a collection or array field in a class annotated with @Document
the nested edge objects are fetched from the database. Each of the nested edge objects has to be stored as separate edge document in the edge collection described in the @Edge
annotation of the nested object class with the _id of the parent document as field _from or _to.
@Document("persons")
public class Person {
@From
private List<Relation> relations;
}
@Edge(name="relations")
public class Relation {
...
}
The database representation of Person
in collection persons looks as follow:
{
"_key" : "123",
"_id" : "persons/123"
}
and the representation of Relation
in collection relations:
{
"_key" : "456",
"_id" : "relations/456",
"_from" : "persons/123"
"_to" : ".../..."
}
{
"_key" : "789",
"_id" : "relations/456",
"_from" : "persons/123"
"_to" : ".../..."
}
...
With the annotations @From
and @To
applied on a field in a class annotated with @Edge
the nested object is fetched from the database. The nested object has to be stored as a separate document in the collection described in the @Document
annotation of the nested object class. The _id field of this nested object is stored in the fields _from
or _to
within the edge document.
@Edge("relations")
public class Relation {
@From
private Person c1;
@To
private Person c2;
}
@Document(value="persons")
public class Person {
@Id
private String id;
}
The database representation of Relation
in collection relations looks as follow:
{
"_key" : "123",
"_id" : "relations/123",
"_from" : "persons/456",
"_to" : "persons/789"
}
and the representation of Person
in collection persons:
{
"_key" : "456",
"_id" : "persons/456",
}
{
"_key" : "789",
"_id" : "persons/789",
}
Note: If you want to save an instance of Relation
, both Person
objects (from & to) already have to be persisted and the class Person
needs a field with the annotation @Id
so it can hold the persisted _id
from the database.
With the @<IndexType>Indexed
annotations user defined indexes can be created at a collection level by annotating single fields of a class.
Possible @<IndexType>Indexed
annotations are:
@HashIndexed
@SkiplistIndexed
@PersistentIndexed
@GeoIndexed
@FulltextIndexed
The following example creates a hash index on the field name
and a separate hash index on the field age
:
public class Person {
@HashIndexed
private String name;
@HashIndexed
private int age;
}
With the @<IndexType>Indexed
annotations different indexes can be created on the same field.
The following example creates a hash index and also a skiplist index on the field name
:
public class Person {
@HashIndexed
@SkiplistIndexed
private String name;
}
If the index should include multiple fields the @<IndexType>Index
annotations can be used on the type instead.
Possible @<IndexType>Index
annotations are:
@HashIndex
@SkiplistIndex
@PersistentIndex
@GeoIndex
@FulltextIndex
The following example creates a single hash index on the fields name
and age
, note that if a field is renamed in the database with @Field, the new field name must be used in the index declaration:
@HashIndex(fields = {"fullname", "age"})
public class Person {
@Field("fullname")
private String name;
private int age;
}
The @<IndexType>Index
annotations can also be used to create an index on a nested field.
The following example creates a single hash index on the fields name
and address.country
:
@HashIndex(fields = {"name", "address.country"})
public class Person {
private String name;
private Address address;
}
The @<IndexType>Index
annotations and the @<IndexType>Indexed
annotations can be used at the same time in one class.
The following example creates a hash index on the fields name
and age
and a separate hash index on the field age
:
@HashIndex(fields = {"name", "age"})
public class Person {
private String name;
@HashIndexed
private int age;
}
The @<IndexType>Index
annotations can be used multiple times to create more than one index in this way.
The following example creates a hash index on the fields name
and age
and a separate hash index on the fields name
and gender
:
@HashIndex(fields = {"name", "age"})
@HashIndex(fields = {"name", "gender"})
public class Person {
private String name;
private int age;
private Gender gender
}