Guide

Relationships

Pointer, array of pointers, or Relation — three ways to connect records, and the one question that decides between them.

Three options

HoldsGood up toQuery from
Pointerone referenceeither side
Array of pointersa list, stored on the row~100either side
Relationa list, stored separatelyunboundedthe owning side
The question that decides it How many, and does it grow without limit? One → Pointer. A handful that a human curates (tags, contributors) → Array. Something that grows with usage (a user's orders, a post's comments) → the other way round: put a Pointer on the many 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
Without targetClass the array stays raw 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 Relation only queries one way You can ask "who is in this team". You cannot ask "which teams is this user in" without a query per team. If you need both directions, use a join model instead.

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

RelationshipUse
Task → its projectPointer on the task
Project → its tasksNo field. Query tasks by project
Post → its tagsArray of pointers
Team → its members (many)Relation, or a join model
Team ↔ member with a roleJoin model