Home
> Firestore Tree Data Model
Get updates on future FREE course and blog posts!
Subscribe

Firestore Tree Data Model

15 min read

Jonathan Gamble

jdgamble555 on Sunday, September 22, 2024 (last modified on Sunday, September 22, 2024)

We often need to model tree data in Firestore. Nested comments, categories, graphs, and groups are just some examples. Modeling is difficult, but it can be done with a few hacks.

Firestore Tree

TL;DR#

When you want to query nested data in Firestore, you must model the data so that one query can get all documents. This can be possible by using a long string for the path.

We Need One Query#

You can easily model the data for multiple queries. The original thought was to query all posts where the parent equals some ID. If there were children, query them separately. The problem with this method is that you will have multiple calls to Firestore. While you would be reading the same documents, you don’t want to have several fetches to Firestore on one page, especially when it can be avoided.

Tree Demo in SvelteKit#

This demo uses SvelteKit, so I recommend you read the Firebase SvelteKit post first. There are many advanced techniques, but I am only concentrating on the data model for this post. I use Firebase Client, but I could have equally used Firebase Admin with SvelteKit Form Actions.

Hierarchy#

A tree is data that can belong to other data in a hierarchy. To nest data, we must use startsWith character hack mentioned in the Fuzzy Full-Text Search post. The algorithm will be slightly different, but we will order the path and parent variables to get the desired results.

Tree Data Model#

We are building a comment system similar to a Reddit Clone, where people can comment on one post in a tree fashion.

Short Document ID#

We will create a short ID for faster searching and longer string storage. Theoretically, a document can be as big as 1MB, so one field could be up to that large.

	const id = doc(
    collection(db, 'comments')
).id.substring(0, 5);

This limits the document ID to 5 characters, although you can use whatever length you like.

Creating a Comment Document#

We will create the document with a few fields.

	export const addComment = async (event: SubmitEvent) => {

    const { text, parent: _parent, formElement } = getComment(event);

    const level = _parent.split('/').length + 1 || 1;
    const parent = _parent.split('/').join('_');

    const currentUser = auth.currentUser;

    if (!currentUser) {
        throw 'No user!';
    }

    // create short ID
    const id = doc(
        collection(db, 'comments')
    ).id.substring(0, 5);

    const path = parent ? `${parent}_${id}` : id;

    try {
        await setDoc(
            doc(db, `comments/${id}`),
            {
                createdBy: currentUser.uid,
                createdAt: serverTimestamp(),
                text,
                level,
                path,
                parent,
                votes: 0
            }
        );
        formElement.reset();

    } catch (e) {
        if (e instanceof FirebaseError) {
            console.error(e);
        }
    }
};
  • text field stores the comment
  • level stores the level number from the root
  • path stores the full path to the document. / will be replaced with _ for searching.
  • parent stores the full path to the parent document, or null. / will be replaced with _ for searching.
  • votes is not used in this demo, but could be used to create a voting mechanism similar to Reddit.

Deleting a Comment#

When we delete a comment, we must also delete all of its children. We search for them, then use batch to delete them in a transaction.

	export const deleteComment = async (path: string) => {

    const _path = path.split('/').join('_');

    const childrenSnap = await getDocs(
        query(
            collection(db, 'comments'),
            ...startsWith('path', _path)
        )
    );

    if (childrenSnap.empty) {
        throw 'No ids!';
    }

    const batch = writeBatch(db);

    try {
        childrenSnap.docs.map(doc => {
            batch.delete(doc.ref);
        });
        await batch.commit();
    } catch (e) {
        if (e instanceof FirebaseError) {
            console.error(e);
        }
    }
};

Starts With#

We have a shorter version of the startsWith in Fuzzy Text Search. This allows you to search for something which starts with a character. We need to use it with the spread operator, as we cannot return more than one item, only an array of items.

	export const startsWith = (
    fieldName: string,
    term: string
) => {
    return [
        orderBy(fieldName),
        startAt(term),
        endAt(term + '~')
    ];
};

Querying the Tree#

We query the tree by searching for comments ordered by the parent document. If we want the votes feature, we order by the votes secondly. This demo does not use votes, but the feature could easily be added.

	export const useComments = (
    term: string | null = null,
    levels: number[] = []
) => {

    const user = useUser();

    // filtering comments depend on user
    return derived<
        Readable<UserType | null>,
        CommentType[]
    >(
        user, ($user, set) => {
            set([]);
            if (!$user) {
                return;
            }
            const queryConstraints = [];
            if (term) {
                const _term = term.split('/').join('_');
                queryConstraints.push(
                    where('path', '>=', _term),
                    where('path', '<=', _term + '~')
                );
            }
            if (levels?.length) {
                queryConstraints.push(
                    where('level', 'in', levels)
                );
            }
            return onSnapshot(
                query(
                    collection(db, 'comments'),
                    where('createdBy', '==', $user.uid),
                    orderBy('path'),
                    orderBy('votes', 'desc'),
                    ...queryConstraints
                ), (q) => set(snapToData(q))
            );
        });
};

If we enter a term when creating this query, it will only display items under that parent. This is where we need the startsWith hack again.

Snapshot Data#

We must take our data and modify it in a tree format. Remember our data is sorted correctly, but we need to display it in a tree fashion.

	export const snapToData = (
    q: QuerySnapshot<DocumentData, DocumentData>
) => {

    // creates comment data from snapshot
    if (q.empty) {
        return [];
    }
    const comments = q.docs.map((doc) => {
        const data = doc.data({
            serverTimestamps: 'estimate'
        });
        const createdAt = data['createdAt'] as Timestamp;
        const comment = {
            ...data,
            path: data['path']?.split('_').join('/'),
            parent: data['parent']?.split('_').join('/'),
            createdAt: createdAt.toDate(),
            id: doc.id
        };
        return comment;
    }) as CommentType[];

    // create children from IDs
    const _comments = nestedComments(comments);
    if (dev) {
        console.log(_comments);
    }
    return _comments;
}

Our parent and path are reformed to a path format.

Threaded Comments#

To get our data in the right place, we need to put all children in a children array, and all their children in a children array etc. This will allow us to display the data correctly in our component template.

	export const nestedComments = (comments: CommentType[]) => {
    const commentMap: Record<string, CommentType> = {};
    const result: CommentType[] = [];

    // Initialize the commentMap and set up children arrays
    for (let i = 0; i < comments.length; i++) {
        const comment = comments[i];
        comment.children = [];
        commentMap[comment.path] = comment;
    }

    // Nest comments under their parents, or handle cases where parent is missing
    for (const comment of comments) {
        // If the comment doesn't have a parent, add it to the top-level result immediately
        if (!comment.parent) {
            result.push(comment);
            continue;
        }

        // If the parent is not in the dataset, handle the child as a top-level comment (or change as needed)
        const parent = commentMap[comment.parent];
        if (!parent) {
            result.push(comment);
            continue;
        }

        // Add the comment to the parent's children array if the parent exists
        parent.children!.push(comment);
    }

    return result;
};

Recursive Component#

Now our data is ready to be displayed recursively in our component.

	<script lang="ts">
    import Comment from '$lib/components/comment.svelte';
    import Comments from '$lib/components/comments.svelte';

    export let comments: CommentType[];
</script>

{#each comments as comment}
    <Comment text={comment.text} path={comment.path}>
        {#if comment.children?.length}
            <Comments comments={comment.children} />
        {/if}
    </Comment>
{/each}

Depth#

Finally, we may want to display only a certain depth in our tree. Remember, a depth is calculated from the current post in whatever position it may be in. For example, a comment in the 5th position but the 3rd position on that page will have a depth of 5 on the main page and 3 on that comment page.

	where('level', 'in', levels)

We calculate the levels on that page and add them to the query in an array (or multiple where clauses). Three positions starting at level 3, will have a value of [3, 4, 5] in the levels array. This works as a filter, so we don’t over-fetch unneeded data.

About the Demo Tree#

This demo creates each comment tree using the user’s ID. A real comment tree will probably use a post ID. This is only for demo purposes, so users can have their own tree; we don’t want spam.

Bonus: Votes#

With votes being the second orderBy clause, you could easily modify this demo to be a Reddit Clone. Up and Down clicks on the votes would increment the votes counter on the post. I didn’t do that for simplification, but it would be an easy addition.

⚠️ Don’t forget to add the necessary indexes from the console.log when you run the demo locally using your database.

Demo: Vercel Serverless

Repo: GitHub


Related Posts

© 2024 Code.Build