Object schema
Introduction
Even though the information stored in the platform can seem to be normal JSON, it is not. If you haven’t read about the Essential Concepts of Linked Data don’t worry, you won’t need them to understand the basics behind object schemas, but it wouldn’t hurt you to read them either.
The platform stores each document as a set of statements (or RDF triples). These statements together comprise an entity or resource. For example, the following object:
var project = {
name: "Project 1",
labels: [
"critical",
"hr",
"example"
],
createdOn: new Date( 2016, 9, 25 )
};
Would be saved as statements that can be read:
- project has the name, Project 1 (which is a string)
- project has the label, critical (which is a string)
- project has the label, hr (which is a string)
- project has the label, example (which is a string)
- project was createdOn, 2016/09/25 (which is a date)
There are a couple of things to notice here:
- The platform saves the type of each property’s value
- It doesn’t save information about the value of the property label being an array
- The statements aren’t ordered
Because of this there are several common pitfalls that you can fall into when developing web applications with Carbon:
- If you save a document with a property that has an array with only one value, and later on you retrieve the document, the property will point to the value directly. It won’t be in an array.
- Any application that depends on an array keeping its order will behave randomly.
- If your Carbon application is being used by several web applications, a web application could save statements about a property that other web applications don’t expect (nor understand).
All of these pitfalls can be overcome by using Object Schemas.
What is an object schema?
An object schema provides information about the properties the SDK can expect. On it, a web application can force the SDK to treat values as arrays, force arrays to be ordered, tell the SDK to ignore values of unknown types, etc.
Object schemas are stored inside of a context. Each context has it’s own object schema, and child contexts inherit their object schema from their parent’s.
To provide new information to the object schema the context.extendObjectSchema
method can be used:
appContext.extendObjectSchema( {
// New information
} );
The object passed to the extendObjectSchema
method is a @context
object defined in the JSON-LD specification. If you aren’t familiar with that specification, don’t worry. You don’tneed to understand the specification to be able to use object schemas, but again, the more information you posses, the better.
Properties inside of the object passed to extendObjectSchema
will describe different aspects of the schema your application will use.
Properties
To provide information about the properties your application objects will have, a property with the same name needs to be defined on the object schema:
appContext.extendObjectSchema( {
"labels": {
// Metadata of the "labels" property
}
} );
Collections (@container)
Properties by default are assumed to be one value only unless proven otherwise. That’s why if you save a document with a property that holds an array with one value, once you retrieve it you’ll receive only the value, not the array.
To specify that a property is always going to hold an array, you can declare the @container
property on the object that describes the property.
That property will need to have one of the following values depending on what collection type you want to have:
@set
: for unordered arrays (meaning retrieving a document with that property several times will yield different results)@list
: for ordered arrays (keep in mind that this type of collection takes more space and is more expensive overall)@map
: for language based maps (TO BE DOCUMENTED)
For example, imagine that the application handled objects like the following one:
var project = {
labels: [
"important"
],
tasks: [
{
title: "Do something"
},
{
title: "Then do something else"
},
]
};
The labels
property is going to have a collection as a value, but it doesn’t need to keep it’s order. After all, its only valuable information is that it “has” this and that label.
On the other hand, the tasks
property needs to keep the order of tasks because it indicates the order of their execution or importance.
The object schema should be extended as follows:
appContext.extendObjectSchema( {
"labels": {
"@container": "@set"
},
"tasks": {
"@container": "@list"
}
} );
Having done that, persisting the resource and then reading it again will yield the expected results.
Data types (@type)
The SDK does a really good job on saving values with the right data type. Strings will be saved as strings, booleans will be saved as booleans, etc. But imagine the following scenario:
- Application A and B are developed using the same Carbon application to store their data
- Both applications are programmed to understand Project objects with a property “content” that holds a string
- Later on complex “content” properties are added to application B. Now app B understands “content” properties that hold objects (with a type, and other properties).
Because application A hasn’t been updated, and both apps are using the same data, app A is probably going to crash whenever it finds a complex “content” property .
To avoid this, it is a good practice to define what type the property is meant to have on each application. This is done by specifying a @type
property on the object that describes the property “content”:
appContext.extendObjectSchema( {
"content": {
"@type": "string"
}
} );
If the application A has its object schema set this way it won’t have a problem. The SDK will filter any “content” non string value when retrieving the project.
The most common accepted types are:
@id
: When the value type is a pointer to another objectstring
boolean
integer
double
float
long
date
dateTime
time
All of these types (except @id
) are part of the xml schema data types.
IDs (@id)
So far we have solved all of the problems we stated at the start of this document. But there’s another problem lurking around. Imagine the following scenario:
- Application A and application B want to share data by using the same Carbon app
- Application A handles objects like the following one:
var client = { name: "Miguel", lastName: "Aragón" };
- On the other hand application B was programmed to handle objects like this:
var client = { firstName: "Cody", lastName: "Burleson" };
These two applications will not be able to integrate because they use a different property for the same purpose. Application A uses name
to refer to first names, and application B uses firstName
instead.
There are two options: one application renames the property to use the same as the other application (expensive!), or they define the same @id
on their own object schema.
The property @id
configures the unique id the property will have when saved on the platform. That way even if two or more applications are using different property names for the same “real” property, they can integrate by using a unique id for that property.
In this example the problem would be solved by following these steps:
- Application A extends its object schema like this:
appContext.extendObjectSchema( { "name": { "@id": "lastName", "@type": "string" } } );
- Application B extends its this way:
appContext.extendObjectSchema( { "lastName": { "@id": "lastName", "@type": "string" } } );
With that setup, the SDK will translate the properties when saving or retrieving documents and both of the applications will be using the same standardized name for their properties.
Vocabularies
Storing properties with names like name
or title
is meant to cause collisions. A Carbon application could use “name” to define people’s first names while another one could use it to define their complete name. Or even worse, they could use it to define the name of a country.
That’s why by default each time a new property is saved to a Carbon app it is saved as a uniform resource identifier (URI). For example, if your JavaScript application saves an object with a property title
, the property will actually be saved as something like:
http://localhost:8083/apps/your-app-slug/vocabulary/#title
Let’s decompose that URI:
http://
: The protocol your Carbon platform is configured withlocalhost
: The address of your Carbon platform:8083
: The port the Carbon platform is listening onapps/your-app-slug/
: Your Carbon app slugvocabulary/
: A document inside of your Carbon app#title
: The slug of a fragment inside of thevocabulary/
document
This means that the property is saved using the id of a fragment, inside of a document called vocabulary/
from your application.
By doing this, you are able to go and save data about the property itself. You could for example go and add a description to the property by modifying the vocabulary/
document:
let appContext;
// ... retrieve appContext
appContext.get( "vocabulary/" ).then(
( [ vocabulary, response ] ) => {
let title = vocabulary.getFragment( "title" );
// Add metadata about the title property
title.description = "The title of a book";
return vocabulary.save();
}
).then(
( response ) => {
// ...
}
).catch( console.error );
import * as App from "carbonldp/App";
import * as HTTP from "carbonldp/HTTP";
import * as PersistedDocument from "carbonldp/PersistedDocument";
import * as PersistedFragment from "carbonldp/PersistedFragment";
let appContext:App.Context;
// ... retrieve appContext
appContext.get( "vocabulary/" ).then(
( [ vocabulary, response ]:[ PersistedDocument.Class, HTTP.Response.Class ] ) => {
let title:PersistedFragment.Class = vocabulary.getFragment( "title" );
// Add metadata about the title property
title.description = "The title of a book";
return vocabulary.save();
}
).then(
( response:HTTP.Response.Class ) => {
// ...
}
).catch( console.error );
var appContext;
// ... retrieve appContext
appContext.get( "vocabulary/" ).then(
function( result ) {
var vocabulary = result[ 0 ];
var title = vocabulary.getFragment( "title" );
// Add metadata about the title property
title.description = "The title of a book";
return vocabulary.save();
}
).then(
function( response ) {
// ...
}
).catch( console.error );
vocabulary
because it collects all of the terms you use in your application. By developing an application you are essentially creating a new vocabulary.If that’s the case you may think: “Well what if I reuse the terms from one application on another one?”. Well, that’s one of the main advantages of building a vocabulary!If you have a Carbon application (application A) with an already established amount of terms, and you want to create a new Carbon app (application B) that understands data from your already existing application you can accomplish it by defining the @id
of the property in the object schema:
// In application B
appBContext.extendObjectSchema( {
"title": {
"@id": "http://localhost:8083/apps/application-a-slug/vocabulary/#title"
}
} );
Note: This is only needed if your applications are not sharing a Carbon application.
Sharing vocabularies opens a lot of interesting possibilities. For example, you could create your own vocabulary that only contains terms about social connections. Or one that is only about geo location terms. Later on you could make those vocabularies public so other people could use them and make their data compatible with each other!
Well guess what? People have been doing that for quite some time. There are a lot of well established vocabularies that you can use on your application to make your data worldwide compatible. You can search for these vocabularies using the Linked Open Vocabulary searcher.
It is highly recommended to use existing vocabularies instead of new, custom ones.
Prefixes
If you start using other vocabularies (either from other Carbon apps, or from well established vocabularies), you might find it cumbersome to have to write complete URIs when extending object schemas.
Prefixes come to the rescue! A prefix is a short keyword that can be used as a base of a URI. For example, having the following URI:
http://localhost:8083/apps/application-a-slug/vocabulary/#title
You could define the prefix appA
and make it equivalent to http://localhost:8083/apps/application-a-slug/vocabulary/#
. That way each time you wanted to use that vocabulary you would only need to write appA:title
.
Prefixes can be defined in the object schema by defining a property with a string value. The previous example could be defined like this:
appContext.extendObjectSchema( {
"appA": "http://localhost:8083/apps/application-a-slug/vocabulary/#",
"title": {
"@id": "appA:title",
"@type": "string"
},
"content": {
"@id": "appA:content",
"@type": "string"
}
} );
Object types
There’s a problem left to solve. Imagine an application programmed to handle book
objects and blog post
objects like the following:
var book = {
title: "The most awesome book you'll ever read"
};
var blogPost = {
title: "How jellyfish age"
};
Here the problem lies in that both objects use the same property name, title
, but they use it for different purposes. A book title cannot be interpreted as a blog post title, and if you try to differentiate them by extending the object schema you won’t be able to (at least not in the way we have done it so far) :
appContext.extendObjectSchema( {
"title": {
"@id": "bookTitle"
},
"title": { // ERROR: Duplicate property name
"@id": "postTitle"
}
} );
To make a distinction between them, first you need to tag each object with a different type. Types help you tag objects to treat them differently. With them you can filter a list of objects, apply specific rules depending on their types, make assumptions on the properties they’ll have based on their types, etc.
You can tag objects when saving them as documents:
let appContext;
// ... retrieve app context
appContext.documents.createChild( {
types: [
"Book"
]
} ).then(
( [ book, response ] ) => {
// ...
}
).catch( console.error );
import * as App from "carbonldp/App";
import * as HTTP from "carbonldp/HTTP";
import * as PersistedDocument from "carbonldp/PersistedDocument";
let appContext:App.Context;
// ... retrieve app context
appContext.documents.createChild( {
types: [
"Book"
]
} ).then(
( [ book, response ]:[ PersistedDocument.Class, HTTP.Response.Class ] ) => {
// ...
}
).catch( console.error );
var appContext;
// ... retrieve app context
appContext.documents.createChild( {
types: [
"Book"
]
} ).then(
function( result ) {
// ...
}
).catch( console.error );
Or you can add or remove types from persisted resources with the addType
and removeType
:
let appContext;
// ... retrieve app context
appContext.documents.get( "books/awesome-book/" ).then(
( [ book, response ] ) => {
book.addType( "Book" );
book.removeType( "UncategorizedObject" ); // Not actually a type, just an example
return book.save();
}
).then(
( [ book, response ] ) => {
// ...
}
).catch( console.error );
import * as App from "carbonldp/App";
import * as HTTP from "carbonldp/HTTP";
import * as PersistedDocument from "carbonldp/PersistedDocument";
let appContext:App.Context;
// ... retrieve app context
appContext.documents.get<Book>( "books/awesome-book/" ).then(
( [ book, response ]:[ Book & PersistedDocument.Class, HTTP.Response.Class ] ) => {
book.addType( "Book" );
book.removeType( "UncategorizedObject" ); // Not actually a type, just an example
return book.save<Book>();
}
).then(
( [ book, response ]:[ Book & PersistedDocument.Class, HTTP.Response.Class ] ) => {
// ...
}
).catch( console.error );
var appContext:App.Context;
// ... retrieve app context
appContext.documents.get( "books/awesome-book/" ).then(
function( result ) {
var book = result[ 0 ];
book.addType( "Book" );
book.removeType( "UncategorizedObject" ); // Not actually a type, just an example
return book.save();
}
).then(
function( result ) {
// ...
}
).catch( console.error );
extendObjectSchema
:
appContext.extendObjectSchema( "Book", {
"title": {
"@id": "bookTitle",
"@type": "string"
}
} );
appContext.extendObjectSchema( "BlogPost", {
"title": {
"@id": "postTitle",
"@type": "string"
}
} );
That way the SDK will treat them as different properties. Extending the object schema without specifying a type is called “extending the GLOBAL object schema”, and specifying a type is called “extending the type’s object schema”.
The following rules apply to type’s object schemas:
- Any property or prefix defined in the global schema will also be defined in a type schema
- If a type schema defines a property that the global schema already defined, the type schema property will apply
It is a good practice to define prefixes in the global schema, and properties in a type specific object schema.
Conclusion
The SDK does its best to try to guess the desired outcome of retrieving documents or saving new ones. It tries to parse known data types into native JavaScript types and vice versa. But sometimes there is not enough data for the SDK to correctly guess the expected behavior. To make sure the SDK behaves as expected, a web application can give more information about properties and objects to the SDK through a context’s object schema.
Any context has an object schema that can be extended through the extendObjectSchema
method.
When extending object schemas properties can be described by declaring a property with an object value. The following properties can be inside that object:
@id
: To specify the URI to use for the property@type
: To declare what data types the application expects on that property@container
: To force the SDK to use a specific container type for the property:@set
: Unordered list@list
: Ordered list@map
: Language based map
Prefixes can be defined by declaring a property with a string value. Prefixes can be used to shorten any URI.