Tigris provides a transactional NoSQL document database, this allows you to create NoSQL data models representing relations in a few different ways to get the best performance, consistency and ease of use in your application.
In this blog, I show you how to model NoSQL one-to-one relationships using the embedded document and sub-document NoSQL design pattern. We'll get hands-on with Tigris to achieve this, however the NoSQL data modeling design patterns are applicable to other NoSQL databases. We will combine this with Tigris's transactions to make sure that all updates are consistent. I will show how to model NoSQL one-to-many and NoSQL many-to-many relationships in future posts as part of the NoSQL Data Modeling Series.
Getting startedโ
To follow along, you will need to get set up with Tigris. This is a quick and
easy process. The Tigris Docs have a
Quickstart to show you how to
sign up. Once you have signed up, create a new project called relations
. Once
created, run the following command to create a local application that we can use
throughout this guide:
$ npx create-tigris-app@latest --project relations --example playground
Follow the prompts to add your clientId
and clientSecret
.
Embedded one-to-one NoSQL data modelingโ
The first way to model NoSQL one-to-one relationships between models is to embed
the model in the document. Let's do that in Tigris, by modeling a User
and a
Location
. In our project, there is already a user.ts
file under
src/db/models
. Change that file to contain two classes like this:
import {
Field,
PrimaryKey,
TigrisCollection,
TigrisDataTypes,
} from "@tigrisdata/core";
export class Location {
@Field()
country: string;
@Field()
address: string;
@Field()
code: string;
}
@TigrisCollection("user")
export class User {
@PrimaryKey({ order: 1, autoGenerate: false })
email: string;
@Field()
name: string;
@Field({ elements: Location })
location: Location;
}
This creates a Location
object embedded inside the User
schema. This allows
us to store and read the Location
and User
in one query. The User's email
address is the primary key.
Let's create a few users and query them. In the src/index.ts
file, let's
change the file to look like this:
import { Tigris } from "@tigrisdata/core";
import { User } from "./db/models/user";
// setup client
const tigrisClient = new Tigris();
async function setup() {
// ensure branch exists, create it if it needs to be created dynamically
await tigrisClient.getDatabase().initializeBranch();
// register schemas
await tigrisClient.registerSchemas([User]);
}
async function main() {
await setup();
const db = await tigrisClient.getDatabase();
const userCollection = await db.getCollection<User>(User);
await userCollection.insertMany([
{
email: "eddie@temp.com",
name: "Eddie Vedder",
location: {
country: "USA",
address: "1 Red Road",
code: "8878",
},
},
{
email: "Jimi@temp.com",
name: "Jimi Hendrix",
location: {
country: "USA",
address: "13 Yellow Road",
code: "1234",
},
},
{
email: "johnny@temp.com",
name: "Johnny Clegg",
location: {
country: "RSA",
address: "87 Green Road",
code: "9087",
},
},
]);
let user = await userCollection.findOne({ filter: { name: "Eddie Vedder" } });
console.table(user);
let userCursor = await userCollection.findMany({
filter: { "location.country": "USA" },
});
for await (user of userCursor) {
console.table(user);
}
}
main()
.then(async () => {
console.log("Query complete ...");
process.exit(0);
})
.catch(async (e) => {
console.error(e);
process.exit(1);
});
Let's unpack what is happening. In our setup
function we are setting up the
Tigris NoSQL database, creating our user
collection and defining the schema
for that collection.
Then in our main
function, we get the database object and then fetch our
user
collection object so that we can work with our collection. We create 3
new users. And then show how to query for one on line 48, or if we want to query
many of them based on a filter, we do that on line 51.
In the above example, we call the setup()
function to check the database and
update the schema. In a production application this only needs to be called when
the schema has changed.
Reference one-to-one NoSQL relationshipsโ
There can be cases where our embedded objects are very large and we do not need
to load them every time. In these cases, we can split the document into its own
collection and fetch it when we need it. Lets add a short biography
about the
users we have added above and put it in a separate collection so that we can
load it when we need to.
We are going to add a new biography
object in our user.ts
so the file now
looks like this:
import {
Field,
PrimaryKey,
TigrisCollection,
TigrisDataTypes,
} from "@tigrisdata/core";
export class Location {
@Field()
country: string;
@Field()
address: string;
@Field()
code: string;
}
@TigrisCollection("biography")
export class Biography {
@PrimaryKey(TigrisDataTypes.UUID, { order: 1, autoGenerate: true })
id?: string;
@Field()
userId: string;
@Field()
description: string;
}
@TigrisCollection("user")
export class User {
@PrimaryKey({ order: 1, autoGenerate: false })
email: string;
@Field()
name: string;
@Field({ elements: Location })
location: Location;
}
As you can see, we have defined a new class, Biography
and given it the
collection name biography
. It also has a field userId
, which is a reference
back to the User
and will contain the user's email address. We can now update
our creating and reading of users to include the biography information.
Change the index.tx
to look like this:
import { Tigris } from "@tigrisdata/core";
import { User, Biography } from "./db/models/user";
// setup client
const tigrisClient = new Tigris();
async function setup() {
// ensure branch exists, create it if it needs to be created dynamically
await tigrisClient.getDatabase().initializeBranch();
// register schemas
await tigrisClient.registerSchemas([User, Biography]);
}
async function main() {
await setup();
const db = await tigrisClient.getDatabase();
const userCollection = await db.getCollection<User>(User);
const bioCollection = await db.getCollection<Biography>(Biography);
const tx = await db.beginTransaction();
await userCollection.insertMany(
[
{
email: "eddie@temp.com",
name: "Eddie Vedder",
location: {
country: "USA",
address: "1 Red Road",
code: "8878",
},
},
{
email: "Jimi@temp.com",
name: "Jimi Hendrix",
location: {
country: "USA",
address: "13 Yellow Road",
code: "1234",
},
},
{
email: "johnny@temp.com",
name: "Johnny Clegg",
location: {
country: "RSA",
address: "87 Green Road",
code: "9087",
},
},
],
tx
);
await bioCollection.insertMany(
[
{
userId: "eddie@temp.com",
description:
"Eddie Jerome Vedder is an American singer, musician, and songwriter best known as the lead vocalist and one of three guitarists of the rock band Pearl Jam.",
},
{
userId: "jimi@temp.com",
description:
'James Marshall "Jimi" Hendrix was an American guitarist, singer and songwriter.',
},
{
userId: "johnny@temp.com",
description:
"Jonathan Paul Clegg, OBE OIS was a South African musician, singer-songwriter, dancer, anthropologist and anti-apartheid activist, some of whose work was in musicology focused on the music of indigenous South African peoples.",
},
],
tx
);
await tx.commit();
let user = await userCollection.findOne({ filter: { name: "Eddie Vedder" } });
console.table(user);
const readTx = await db.beginTransaction();
let userCursor = await userCollection.findMany(
{
filter: { "location.country": "USA" },
},
readTx
);
for await (user of userCursor) {
let userBio = await bioCollection.findOne(
{ filter: { userId: user.email } },
readTx
);
console.log(user);
console.log(userBio);
}
}
main()
.then(async () => {
console.log("main complete ...");
process.exit(0);
})
.catch(async (e) => {
console.error(e);
process.exit(1);
});
We have added the biography
as a collection in the setup
function. Then in
main
, we created a transaction before we insert our user
and biography
documents. We pass this transaction to both insert
functions so that both
insert operations will either complete successfully, or the transaction will
roll the inserts back. This makes sure that writing to both collections is safe
and consistent. We never have to worry about our data not being accurate or
correct.
Now we can read from the two collections. We do this at Line 33. We start
another read transaction and first read from the userCollection
as we stream
those results back we also fetch the biography
of the user from the
bioCollection
. Again we use a transaction to make sure that we are reading a
consistent snapshot of the data and that no modifications will affect the
queries.
This blog post is a fast overview of NoSQL data modeling approaches to one-to-one relations. We also demonstrated using the power of Tigris's transactions to give you a few options depending on your use case. In the next blog post, I will show how to model NoSQL one-to-many relations.
Stay connected
Make sure you don't miss the next post in the series by subscribing to the Tigris Newsletter: