JSON Document Storage
Learn how to store unstructured, binary JSON data in ManagedObject<T>
properties.
JSON Columns in Relational Databases
PostgreSQL supports many column data types like integers, strings, booleans and dates. A column may also be JSON data. This allows for storing unstructured data and simple objects in a table column. The data from JSON columns can be fetched all at once, or in pieces. Elements of JSON data can be used to filter the results of a query.
The Document Data Type
JSON document columns are added to a database table by declaring a Document
property in a ManagedObject<T>
's table definition. In PostgreSQL, a Document
column data type is jsonb
. A document column can only contain JSON-encodable data. This data is typically a Map
or List
that contains only JSON-encodable data. The following ManagedObject<T>
declaration will have a contents
column of type jsonb
.
class Event extends ManagedObject<_Event> implements _Event {}
class _Event {
@primaryKey
int id;
@Column(indexed: true)
DateTime timestamp;
Document contents;
}
A Document
object has a data
property to hold its JSON-encodable data. When instantiating Document
, this property defaults to null unless a value has been provided to the optional, ordered parameter in its constructor.
final doc = new Document();
assert(doc.data == null);
final doc = new Document({"key": "value"});
assert(doc.data is Map);
final doc = new Document([0]);
assert(doc.data is List);
The data in a document can be accessed through its data
property, or through its subscript operator. Document
's subscript operator forwards the invocation to its data
property.
final doc = new Document({"key": "value"});
assert(doc["key"] == doc.data["key"]);
The argument to the subscript operator may be a string (if data
is a map) or an integer (if data
is a list).
Basic Operations on Document Properties
Document
columns are like any other type of column, and can therefore be set during an insert or update, and read during a fetch.
Inserting Rows with Document Properties
A Document
property is first set when inserting with a Query<T>
. The values
property of the query is set to a Document
object initialized with a JSON-encodable value.
final query = Query<Event>(context)
..values.timestamp = DateTime.now()
..values.contents = Document({
"type": "push",
"user": "bob",
"tags": ["v1"]
});
final event = await query.insert();
In the above, the argument to Document
will be JSON-encoded and stored in the database for column contents
. If the object can't be encoded as JSON, an exception will be thrown.
Fetching Rows with Document Properties
When fetching an object with Document
properties with a Query<T>
, you access the column's value through the document's data
property.
final query = Query<Event>(context)
..where((e) => e.id).equalTo(1);
final event1 = await query.fetchOne();
event1.contents.data == {
"type": "push",
"user": "bob",
"tags": ["v1"]
};
When fetching Document
properties, the JSON data is decoded into the appropriate type. This is likely a Map
or List
, but can be any JSON-encodable object. Because the data stored in a Document
property is unstructured, the type of data
is dynamic
. It is good practice to store consistent data structures in a column; i.e., always storing a Map
or always storing a List
.
Updating Rows with Document Properties
Updating a row with Document
properties works the same as inserting rows.
final query = Query<Event>(context)
..where((e) => e.id).equalTo(1)
..values.contents = Document({
"type": "push",
"user": "bob",
"tags": ["v1", "new"]
});
final event = await query.updateOne();
When updating in this way, the document stored in the column is replaced entirely.
Accessing Document Values
The type of Document.data
is dynamic
- it can be any valid JSON type and may be casted to the expected type when used. This data can also be nested - a List
of Maps
, for example. When accessing object keys or list indices, you may use the subscript operator directly on Document
.
// Object Access by key
final doc = Document({"key": "value"});
final value = doc["key"] == "value";
// List Access by index
final doc = Document(["v1", "v2"]);
final value = doc[0] == "v1";
You can access nested elements with the same syntax:
final doc = Document([
{"id": 1},
{"id": 2}
]);
final obj1 = doc[0]["id"]; // == 1
final obj2 = doc[1]["id"]; // == 2
Note that using the subscript operator on a Document
simply invokes it on its data
property. Therefore, any subscript values must be valid for Dart List
and Map
types.
Fetching Sub-documents
When fetching a Document
property, the default behavior is to return the entire JSON document as it is stored in the database column. You may fetch parts of the document you need by using Query.returningProperties
and the subscript operator.
final query = Query<Event>(context)
..returningProperties((e) => [e.id, e.contents["tags"]]);
final eventsWithTags = query.fetch();
When using the subscript operator on a returned Document
property, only the value for that key is returned. For example, if the above query were executed and the stored column's value were:
{
"type": "push",
"user": "bob",
"tags": ["v1"]
}
The value of Event.contents
would only contain the array for the key "tags":
["v1"]
You may also index arrays in a JSON column using the same subscript operator, and the subscript operator can also be nested. For example, the following query would fetch the "tags" array, and then fetch the string at index 0 from it:
final query = Query<Event>(context)
..returningProperties((e) => [e.id, e.contents["tags"][0]]);
final eventsWithFirstTag = await query.fetchOne();
eventsWithFirstTag.contents.data == "v1";
If a key or index does not exist in the JSON document, the value of the returned property will be null. For this reason, you should use null-aware operators when accessing Document.data
:
final query = Query<Event>(context)
..returningProperties((e) => [e.id, e.contents["tags"][7]]); // 7 is out of bounds
final eventsWithFirstTag = await query.fetchOne();
if (eventsWithFirstTag.contents?.data == "v1") {
...
}
When fetching elements from a JSON array, you may use negative indices to specify a index from the end of the array.
final query = Query<Event>(context)
..returningProperties((e) => [e.id, e.contents["tags"][-1]]);
final eventsWithLastTag = await query.fetchOne();
Note that you can only fetch a single sub-structure from a Document
column per query. That is, you may not do the following:
// Invalid
final query = Query<Event>(context)
..returningProperties((e) => [e.id, e.contents["type"], e.contents["user"]]);
For operations not supported by Query<T>
, you may use SQL directly:
final eventTagCounts = await context.persistentStore.execute("SELECT jsonb_array_length(contents->'tags') from _Event");