# Relationships
Once you have defined relationships inside of your models, you can make use of them through the Record
class. The value of relationships is not only that you can create links between entities but that you can maintain denormalized two-way links between these entities without any effort from the consumer of the Firemodel library.
This two way links allow easy navigation from both sides of the relationship to the other. In traditional Relationsional DB's this is all addressed for you assuming you keep your data model to 2nd (or 3rd) normal form but this linking is left up to you in a database like Firebase. Fortunately when using Firemodel this linking is done for you and you as the consumer of Firemodel can go back to using relationships and not worrying about maintaining them.
In Firemodel you will first define the relationships in the model definitions (see modeling Relationships) and then use them with the API provided by the Record
class. There are two API's you will use depending on the cardinality (see next section) of the relationship or you can opt to use a more generic syntax and use the same API for all relationship cardinalities. Here are some simple examples:
const joe = await Record.add(Person, { name: "Joe", age: 41 });
// examples of hasMany API
await joe.addToRelationship("skillset", "project-mgmt");
await joe.addToRelationship("skillset", "programming-c++");
await joe.addToRelationship("skillset", "programming-java");
// example of hasMany with alias syntax
await joe.associate("skillset", "programming-javascript");
// example of hasOne API
await joe.setRelationship("employer", "microsoft");
// example of hasOne with alias syntax
await joe.associate("boss", "12355454")
# Cardinality / Perspective
Before we talk about hasOne
and hasMany
relationships we must define what we mean by them.
For those of you coming from the days of relational databases, you'll certainly recognize the
term cardinality which describes the relationship between two entities/models.
If we consider a relationship between a Person
model and a Company
model it might look like this:
In this case the cardinality is 1:M
if we consider from the perspective of the Person
but it
would be M:1
from the perspective of the Company
. This perspective is important to
determine if you should be using a hasOne
or hasMany
API. Whenever the side of the relationship which you're operating from is a M than you should be using hasMany
otherwise hasOne
.
Extending our example, if you were considering from the perspective of the Person
:
// Person has 1:M to Company, which means from person perspective
// we should use the `hasOne` API
await person.setRelationship(Company, '12345');
# hasOne ( 1:M
/ 1:1
)
A hasOne
relationship -- also aliased to belongsTo
and ownedBy
-- is any relationship
where the Model we are working with has a 1
cardinality as it relates to the foreign model.
# Methods
setRelationship
- sets the relationship to FK reference; if there existed a FK relationship before it will be removed first.clearRelationship
- clears the FK relationship; no error if there was no pre-existing FK
# Aliases
You can also use:
associate
- an alias to "setRelationship" forhasOne
relationships, anddisassociate
- an alias to "clearRelationship" forhasOne
relationship.
The primary reason to use these aliases is to ensure that a common API can be used across all relationship types where as the explicit API is potentially a more descriptive set of verbs for what is actually happening.
# Examples
In the scenario of a many to one we will make use of the setRelationship
method.
// get an existing company based on id
const abcCorp = await Record.get(Company, 'abc-corp')
// create and save a new person
const newPerson = await Record.add(Person, {
name: "Tom Bart",
age: 38
})
// add the new person into a relationship with the company
await newPerson.associate('company', abcCorp.id)
The associate
method will take an array of strings as the second argument when your model contains a hasMany
property.
abcCorp.associate('people', [newPerson.id, newPerson2.id])
To remove a relationship, we can make use of the disassociate
method, this method is making use of the underlying removeFromRelationship
method.
// create and save a new person
const harryKhan = await Record.get(Person, 'harry-khan')
// get an existing company based on id
const abcCorp = await Record.get(Company, 'abc-corp')
// add the new person into a relationship with the company
abcCorp.disassociate('people', harryKhan.id)
// save updates including the relationship
abcCorp.update({})
The disassociate
method will take an array of strings as the second argument when your model contains a hasMany
property.
abcCorp.disassociate('people', [harryKhan.id, newPerson.id])
# hasMany ( 1:M
, M:M
)
A "hasMany" relationship has a cardinality of either 1:M
or M:M
but the important characteristic from an API standpoint is that the Record which you are working on has
one or more of the FK relationship.
# Methods
The methods you will use to work with a 1:M
relationship are:
addToRelationship
- adds another FK to an existing relationshipremoveFromRelationship
- removes one of the FK relationships on the given propertyclearRelationships
- removes ALL FK relationships of a given property
Note: these same properties can be used for
M:M
relationships; Firemodel will ensure in both cases that the appopriate DB paths are updated
# Aliases
You can also use:
associate
- an alias to "addToRelationship" forhasMany
relationships, anddisaccociate
- an alias to "removeFromRelationship" forhasMany
relationships.
The primary reason to use these aliases is to ensure that a common API can be used across all relationship types where as the explicit API is potentially a more descriptive set of verbs for what is actually happening.
# Examples
To create a relationship we can use this sugar method called associate
, this method is making use of the underlying addToRelationship
method.
// create and save a new person
const newPerson = await Record.add(Person, {
id: 'harry-khan', // normally you'd let firemodel create this for you
name: "Harry Khan",
age: 22
})
// get an existing company based on id
const abcCorp = await Record.get(Company, 'abc-corp')
// add the new person into a relationship with the company
await abcCorp.associate('people', newPerson.id)
// alternatively, we can use the core method for 1:M relns
await abcCorp.addToRelationship('people', newPerson.id);
And then to remove Harry from ABC we would:
const abcCorp = await Record.get(Company, 'abc-corp')
// removes using an alias
await abcCorp.disassociate('people', 'harry-ghan')
// alternatively, we can use the core method for 1:M relns
await abcCorp.removeFromRelationship('people', 'harry-ghan')
And if, in our continuing saga, ABC Corp goes out of business (and therefore employs no one) we can do the following:
const abcCorp = await Record.get(Company, 'abc-corp');
await abcCorp.clearRelationships('people');
# API Options
# API Options
All methods have a trailing options
parameter which offers the following options:
export interface IFmRelationshipOptions {
/**
* Ensure that FK being referenced actually exists; throw error if not.
* Default is false.
*/
validateFk?: boolean;
/**
* Allows the given operation to be executed against the database but to
* NOT send the events to the `dispatch()` function. Default is false.
* In general this should be avoided except for Mock's and in testing
* functions but possibly there are other use cases.
*/
silent?: boolean;
/**
* By default if you set a relationship and that relationship _already_ existed
* then it will be ignored with the assumption that this an affirmation of an
* existing relationship. If instead you want this represent an error you can
* set this to `true` and it will throw the `firemodel/duplicate-relationship`
* error.
*
* If this remains in the default state of `false` and Firemodel can detect this
* state without doing any additional DB queries it will fire a
* `RELATIONSHIP_DUPLICATE_ADD` dispatch event. This shouldn't be relied on but
* can sometimes proactively alert developers in development of unintended
* behavior.
*/
duplicationIsError?: boolean;
}
As is always the case, interfaces or types specified in this documentation will attempt to be up-to-date but they are always exposed by this library so expect the typings that are exported the "official documentation". This typing is defined in
src/@types/relationships
.
# Async Values and Dispatch
# Record
Property Values
When you call any of the relationship methods discussed above the Record
you're operating on will immediately change to reflect the updated values. Of course the value "officially" updating is an asynchronous process so in many cases it's best to call the methods with an await to ensure the change has completed on the database. Let's illustrate with two examples:
// update a relationship and wait until its been update before moving on
await abcCorp.disassociate('employees', '1234');
// make some more reln adjustments but don't wait and do them in parallel
const newEmployee = abcCorp.associate('employees', 'better-employee');
const newProduct = abcCorp.associate('products', 'my-new-product');
// at this point values on local records will be updated
if (abcCorp.get('employees').includes('better-employee')) {
// This WILL evaluate to true
}
// but we can still ensure that all operations are done before we leave
await Promise.all([newEmployee, newProduct]);
# Dispatch
The dispatch functionality is typically used to monitor/cache changes in state where you are operating in long running processes (which is typically the case in modern SPA frontends and more traditional backend servers). Also, since Firebase is a "real time database" it is well suited to providing an event stream approach in communicating state change and is typically a better way of maintaining state than isolated state objects like Record
when you're operating in this long-running environment.
As dispatch
and event streams relates to relationships we cover this somewhat generically in the Dispatch and Events section becuase it's important to understand that the dispatch functionality will also provide information at
each stage of a two-phased transaction. Here we will talk specifically about how this two phased transaction plays out for a relationship.
Here's a diagram that illustrates the dispatch events when we originate the change:
The first thing to note is that Firemodel uses Firebase's often underestimated "multi-path set" operation to ensure that both models are set or neither are. This ensures the atomicity of the transaction. However, in the case of optimistic changes that consumer may choose to make at the stage of the RELATIONSHIP_ADDED_LOCALLY
stage, the rollback event must be used to rollback these changes.
Interestingly, when a relationship is originated outside of Firemodel (not recommended) it will show up as two events. One for each model:
Obviously if some external agent were to try and update a relationship and fail it would result in no events as the database never actually changed.
Note: The biggest risk of external agents making the update is that they may update only one side of a relationship and leave the overall state out of balance.
# Errors
All errors that are encountered will be some derivative of the FiremodelError
class with both code
and name
properties to work off of. You should be sure to always handle errors with
try..catch
blocks although the level in your application/function will be left up to you. Errors you may encounter when working with relationships include:
Reln Specific Error | Record error |
---|---|
fk-does-not-exist | record-not-found |
missing-reciprocal-inverse | dynamic-properties-not-ready |
incorrect-reciprocal-inverse | |
duplicate-relationship | |
not-hasMany-reln |
Always remember, documentation and code can separate so always validate the code whenever you are in doubt. You can refer to the src/errors
directory to have some sense for the errors which you may encounter (but some are not yet in this directory structure).