SHACL and the GraphQL Schema

This section (for maintainers of Ontologies and other RDF/SHACL data models) explains how RDF graphs can be published through the GraphQL services of TopBraid. In a nutshell, one or more GraphQL schemas are automatically generated using data shape definitions in the Shapes Constraint Language (SHACL). These SHACL shapes may be automatically generated using other input GraphQL schemas, enhancing them in the process with numerous features to query data stored in an RDF dataset. SHACL data shapes can also be generated from other input formats supported by TopQuarant’s products.

The readers of this section are expected to be familiar with GraphQL and have basic RDF skills. Decent knowledge of SHACL is advantageous.

This section uses the prefix dash which represents the namespace http://datashapes.org/dash#. The prefix graphql represents the namespace http://datashapes.org/graphql#. Both graphs are automatically included into every TopBraid EDG Ontology.

Selecting the Shapes

An RDF graph may contain thousands of classes or data shapes. A GraphQL service that includes all of them at once would quickly become unusable. In order to instruct the processor on which shapes and classes shall be exposed via GraphQL, the starting point is the “Home” asset of a TopBraid EDG ontology. This schema instance must use the following properties to include or exclude shapes:

Screenshot of TopBraid EDG Ontology editor defining the GraphQL schema properties

Use the GraphQL Schema view of the Ontology’s Home asset to edit what gets exposed by GraphQL

GraphQL Schema generation properties

Property

Description

graphql:publicShape

The values are included into the GraphQL schema

graphql:publicClass

The values and all its subclasses are included

graphql:publicNamespace

All shapes from the given namespace are included

graphql:protectedShape

The values are included but not available from the root query

graphql:protectedClass

The values and all its subclasses are included but not available from the root query

graphql:privateShape

The values are excluded from the GraphQL schema (even if published by other properties)

The algorithm that produces the set of published shapes first collects all shapes or classes defined using the graphql:publicXY and graphql:protectedXY properties above from the schema and also all its transitive values of owl:imports and rdf:type properties. Then it removes those that are marked via graphql:privateShape.

All published shapes can be queried via GraphQL and are automatically exposed by the root query object. Those that are marked protected can not be queried from the root query but can be reached and traversed from other object types. Here is an example:

 1ex:MySchema
 2    a graphql:Schema ;
 3    graphql:publicShape ex:Human .
 4
 5ex:Human
 6    a sh:NodeShape ;
 7    sh:property [
 8        sh:path ex:id ;
 9        sh:datatype xsd:string ;
10        sh:minCount 1 ;
11        sh:maxCount 1 ;
12        sh:order "0"^^xsd:decimal ;
13        graphql:isIDField true ;
14    ] ;
15    sh:property [
16        sh:path ex:name ;
17        sh:datatype xsd:string ;
18        sh:minCount 1 ;
19        sh:maxCount 1 ;
20        sh:order "1"^^xsd:decimal ;
21    ] ;
22    sh:property [
23        sh:path ex:height ;
24        sh:datatype xsd:decimal ;
25        sh:maxCount 1 ;
26        sh:order "2"^^xsd:decimal ;
27    ] ;
28    sh:property [
29        sh:path ex:friends ;
30        sh:node ex:Human ;
31        sh:order "3"^^xsd:decimal ;
32    ].

From these SHACL shapes, the processor will internally generate the following GraphQL schema:

 1schema {
 2	query: RootRDFQuery
 3}
 4
 5type RootRDFQuery {
 6	humans (... filters etc, see later...): [Human]
 7	... generated fields for aggregations and introspection ...
 8}
 9
10type Human {
11	uri: ID!
12	label: String!
13	id (... filters etc...): ID!
14	name (... filters etc...): String!
15	height (... filters etc...): Float
16	friends (... filters etc...): [Human]
17	... generated fields for aggregations, derived values ...
18}

As shown above, the system automatically produces a root query object that has fields for every public shape, with a name that is basically the plural form of the shape name. These root query fields can take a large number of arguments to select which of the matching objects shall be returned, see graphql_querying.

Completing this introductory example, here is an example GraphQL query against this schema, returning all humans where the name starts with L, and all their friends, translating the height from meters to feet.

 1{
 2    humans (where: {name:{pattern:"^L"}}, orderBy: name) {
 3        id
 4        name
 5        height (transform: "$height / 0.3048")
 6        friends {
 7            id
 8            name
 9        }
10    }
11}

A possible result JSON would be:

 1{
 2	"data": {
 3		"humans": [
 4			{
 5				"id": "1003",
 6				"name": "Leia Organa",
 7				"height": 4.921259842519685,
 8				"friends": [
 9					{
10						"id": "1002",
11						"name": "Han Solo"
12					},
13					{
14						"id": "1000",
15						"name": "Luke Skywalker"
16					}
17				]
18			},
19			...
20		]
21	}
22}

Objects and Fields

For each published node shape in a schema, the processor will create one GraphQL object type as described in the following sections.

Object Types for Node Shapes

The name of this object type will be derived using the following rules (in order):

  1. Use the value of graphql:name of the shape.

  2. Use the local name (i.e. the part of the shape URI after a separator such as ‘/’ or ‘#’), replacing ‘-’ with ‘_’, if that is a valid GraphQL name.

If there is more than one object type with the same name (e.g. from different namespaces but with the same local name), then preprend the prefix of the namespace and ‘_’. For example, ex:Human would become ex_Human.

In general, the mapping is rather strict if the underlying shape definitions are invalid. For example if no valid name can be produced for a shape then the schema is rejected and the user encouraged to add suitable graphql:name triples.

The uri Field

Each generated object type has a built-in field called uri that can be used to retrieve the URI of the RDF resource. For blank nodes this is an internal identifier starting with _:. In general, these blank node identifiers can be used interchangeably with URIs.

The label Field

Each generated object type has a built-in field called label that can be used to retrieve a human-readable label for an object. This label is typically derived from the rdfs:label (or a similar property) and should use the preferred language of the client, if multi-lingual labels exist. The label field always returns something, falling back to the local name of the underlying RDF resource, or an internal identifier starting with _: for blank nodes.

Fields for Property Shapes

The object types produced from a node shape will have one field for each distinct sh:path that is defined at any property shape of the node shape. If the node shape is also an rdfs:Class then this includes any property shape of the (transitive) superclasses. Furthermore, any property shapes attached to values of sh:node of the node shape will (recursively) be included. (As a general pattern, rdfs:subClassOf and sh:node are treated uniformly, i.e. sh:node is an extension and inheritance mechanism similar to subclassing.)

The names of these fields are derived using the same rules as for object types, i.e. checking graphql:name first, then local names of the sh:path (if that’s a URI), and prepend a prefix if duplicate names would exist. Note that if a property shape is about a complex SHACL path, then a graphql:name is strongly recommended.

The type of these generated fields is derived from the sh:datatype, sh:node or sh:class. For example, sh:datatype xsd:boolean gets mapped to Boolean and sh:datatype xsd:decimal to Float. To produce ID, annotate the property shape with graphql:isIDField true combined with sh:datatype xsd:string.

If the property shape defines an sh:or list with at least one member, and all members of that list are node shapes with a URI, then a union type will be generated automatically. If the property shape has an sh:or list that is either xsd:string or rdf:langString or its inverse variation rdf:langString or xsd:string then the object type LangString (with fields lang and string) will be used. For other sh:or lists where the first entry is a sh:datatype shape, that specified datatype will be used.

For object-valued properties for which there is no matching GraphQL object type, the system falls back to a built-in special type _Resource that only offers uri and label fields. This type is for example used for links that typically go outside of the published schema, e.g. rdf:type values.

Fields are list-typed unless there is a property shape with sh:maxCount 1. Fields are marked as non-nullable (with !) if there is a sh:minCount 1.

Note that, in general, any property shape that is marked as sh:deactivated true is ignored by the processor.

Defining multiple GraphQL Schemas

A dataset may contain many named graphs and heterogeneous data. It is possible to define multiple GraphQL schemas for the same data, and in the same shapes graph. Each instance of graphql:Schema can either be identified by its URI or by its graphql:name. This is best explained through an example.

1ex:MySchema
2	a graphql:Schema ;
3	graphql:name "starwars" ;
4	graphql:publicShape ex:Human .

The above schema would be available through the URL schema [server]/graphql/[dataset]/starwars.

If a schema does not carry a graphql:name then it can be accessed via the qname of its URI, replacing : with _: [server]/graphql/[dataset]/ex_MySchema would also work.