Guide
Relationships
Pointer, array of pointers, or Relation — three ways to connect records, and the one question that decides between them.
Three options
| Holds | Good up to | Query from | |
|---|---|---|---|
| Pointer | one reference | — | either side |
| Array of pointers | a list, stored on the row | ~100 | either side |
| Relation | a list, stored separately | unbounded | the owning side |
Pointer — one reference
import {
ParseField, ParseClass, BaseModel, BeforeSave, cloneAcl, AfterDelete,
catchError,
} from 'parse-server-kit';
@ParseField({ type: 'Pointer', targetClass: 'Category', required: true })
declare category: any;
targetClass is mandatory — omitting it throws at import, because there is nothing to point at.
// Send it as an id
{ "category": { "objectId": "abc123" } }
// Filter by it
query.equalTo('category', Category.pointer(id));
// Fetch the related row with the result
query.include('category');
// Clear it
{ "category": null }
One to many
Put the pointer on the many side. A project does not hold a list of tasks; each task points at its project.
// Task
@ParseField({ type: 'Pointer', targetClass: 'Project', required: true })
declare project: any;
// "tasks in this project" is then just a query
new Parse.Query(Task).equalTo('project', Project.pointer(projectId));
This scales without limit and needs no write to the project when a task is added. An array on the project would have to be read, modified and saved on every change — and two people adding tasks at once would overwrite each other.
Array of pointers — a small curated list
// targetClass is what makes fromParams convert the ids
@ParseField({ type: 'Array', targetClass: 'Tag' })
declare tags: any[];
{ "tags": [{ "objectId": "t1" }, { "objectId": "t2" }] }
query.equalTo('tags', Tag.pointer(tagId)); // has this tag
query.containsAll('tags', [Tag.pointer(a), Tag.pointer(b)]);
query.include('tags'); // fetch them all
fromParams has nothing to build pointers to, so it stores the plain objects
exactly as sent. The field appears to save and the relationships are not there. A
Pointer without a target throws at import; an Array cannot, because a plain array is
perfectly legitimate.
Keep it small. The whole array is read and written with the row, and two concurrent edits overwrite one another — optimistic locking turns that silent loss into a refusal.
Relation — a large list
Stored outside the row, so size does not affect it and adding does not rewrite the parent.
@ParseField({ type: 'Relation', targetClass: 'User' })
declare members: any;
// Add and remove
team.relation('members').add(user);
await team.save(null, { sessionToken });
// Read - a relation gives you a query, never an array
const members = await team.relation('members').query().find({ sessionToken });
A join model — when the link has its own data
The moment the relationship needs attributes of its own — a role, a joined-at date, an order — it is a record, not a pointer.
@ParseClass('Membership')
export default class Membership extends BaseModel {
constructor() { super('Membership'); }
@ParseField({ type: 'Pointer', targetClass: 'Team', required: true, index: true })
declare team: any;
@ParseField({ type: 'Pointer', targetClass: '_User', required: true, index: true })
declare user: any;
@ParseField({ type: 'String', enum: ['owner', 'editor', 'viewer'] })
declare role: string;
}
Both directions are now ordinary queries, and the pair can be made unique:
compoundIndexes: [{ fields: ['team', 'user'], unique: true }]
Querying across a relationship
// Tasks whose project is archived - a sub-query
const archived = new Parse.Query(Project).equalTo('status', 'archived');
new Parse.Query(Task).matchesQuery('project', archived);
// The inverse
new Parse.Query(Task).doesNotMatchQuery('project', archived);
See Queries for include, select and the cost of each.
Permissions across a relationship
A related row has its own ACL. A task is not private because its project is — you have
to say so. The usual place is the child's @BeforeSave:
@BeforeSave()
static async onBeforeSave(req: Parse.Cloud.BeforeSaveRequest<Task>) {
const task = req.object as Task;
if (!task.existed()) {
const project = await new Parse.Query(Project)
.get(task.project.id, { useMasterKey: true });
const acl = project.getACL();
if (acl) task.setACL(cloneAcl(acl));
}
}
For images and files specifically, syncImageAcl does this for you.
Deleting
Parse does not cascade. Deleting a project leaves its tasks pointing at nothing.
@AfterDelete()
static async onAfterDelete(req: Parse.Cloud.AfterDeleteRequest<Project>) {
const query = new Parse.Query(Task);
query.equalTo('project', req.object);
const [err, orphans] = await catchError(query.find({ useMasterKey: true }));
if (!err && orphans?.length) {
await catchError(Parse.Object.destroyAll(orphans, { useMasterKey: true }));
}
}
Or refuse the delete in @BeforeDelete while children exist. Either is fine;
doing neither leaves data that queries return and clients cannot resolve.
Choosing, briefly
| Relationship | Use |
|---|---|
| Task → its project | Pointer on the task |
| Project → its tasks | No field. Query tasks by project |
| Post → its tags | Array of pointers |
| Team → its members (many) | Relation, or a join model |
| Team ↔ member with a role | Join model |