kossnocorp / typesaurus

🦕 Type-safe TypeScript-first ODM for Firestore
https://typesaurus.com
412 stars 34 forks source link

feature: add `patch` function? #117

Open braaar opened 1 year ago

braaar commented 1 year ago

For a project that uses Typesaurus, I wrote a patch function that, unlike the update function, only updates data on the keys that you provide in your input. This is useful for writing PATCH functionality that let you apply a partial patch to your document.

Say my document in firestore is like this:

const document = {
  nest1: {
    nest2: {
      nest3: {
       nest4: {
        myField: 'Good day, my friend!',
        someOtherField: 'Bye bye, my friend!'
        }
      }
    }
  }
}

If I wanted to only update myField I would provide this as the input to the patch function, expecting only myField to be updated:

const patchInput = {
  nest1: {
    nest2: {
      nest3: {
       nest4: {
        myField: 'Hello world!'
        }
      }
    }
  }
}

If I were to use update, I would overwrite my document and someOtherField would be lost.

Under the hood my patch function uses field to make specific update instructions. It  .flatMaps through Object.entries recursively to create fields for every key-value pair. What's convenient about my proposed function is that you don't have to create these fields yourself. For this specific example it would be trivial (field(['nest1', 'nest2', 'nest3', 'nest4', 'myField'], 'Hello world!')), but for bigger objects this can be quite overwhelming, and maintaining such code can be somewhat laborious.

Is this an interesting feature to have in typesaurus?

For my use case I didn't need deeper nesting than 5 levels, so this would have to be rewritten to allow for the maximum nesting that typesaurus supports. There may very well be a better performing way to write this, and a better typed way to write this. I just took a stab at it and was happy to make it work with types when you use it.

Here is the code for my patch function. It uses createFields to create the field instructions, and then uses them in an update call.

import { Collection, field, update } from 'typesaurus';
import type { Field } from 'typesaurus/field';
import { UpdateModel } from 'typesaurus/update';
import { isPlainObject } from '../shared/utils.js';

/**
 * Partially updates a document by leaving unprovided keys untouched.
 * @param collection - the collection to update document in
 * @param id - the id of the document to update
 * @param data - the document data to update
 */
export function patch<Model>(
  collection: Collection<Model>,
  id: string,
  data: UpdateModel<Model>,
): Promise<void> {
  const fields = createFields(data);
  const onlyDefinedFields = fields.filter(field => field.value !== undefined);
  return update(collection, id, onlyDefinedFields);
}

/** Creates a list of `Field` objects to use for partial updating of firestore documents.
 * Supports four levels of nesting.
 *
 * @example
 *
 * If our document in firestore is defined like this:
 * const document = {
 *   nest1: {
 *     nest2: {
 *       nest3: {
 *        nest4: {
 *         myField: 'Good day, my friend!'
 *         someOtherField: 'Bye bye, my friend!'
 *         }
 *       }
 *     }
 *   }
 * }
 *
 * We can update only `myField` with this as our input:
 * const entity = {
 *   nest1: {
 *     nest2: {
 *       nest3: {
 *        nest4: {
 *         myField: 'Hello world!'
 *         }
 *       }
 *     }
 *   }
 * }
 *
 * const fields = createFields(model);
 *
 * await update(myColletion, id, fields);
 *
 * document.nest1.nest2.nest3.nest4.someOtherField will not be overwritten
 *
 */
export function createFields<Model>(
  object: Model,
  key1?: keyof Model,
  key2?: keyof Model[typeof key1],
  key3?: keyof Model[typeof key1][typeof key2],
  key4?: keyof Model[typeof key1][typeof key2][typeof key3],
): Field<Model>[] {
  return Object.entries(object).flatMap<Field<Model>>(([currentKey, value]) => {
    if (key4) {
      type Key5 =
        keyof Model[typeof key1][typeof key2][typeof key3][typeof key4];
      const typedKey = currentKey as Key5;

      return field<
        Model,
        typeof key1,
        typeof key2,
        typeof key3,
        typeof key4,
        Key5
      >([key1, key2, key3, key4, typedKey], value);
    } else if (key3) {
      const typedKey = currentKey as typeof key4;
      if (isPlainObject(value)) {
        return createFields<Model>(value, key1, key2, key3, typedKey);
      } else {
        return field<Model, typeof key1, typeof key2, typeof key3, typeof key4>(
          [key1, key2, key3, typedKey],
          value,
        );
      }
    } else if (key2) {
      const typedKey = currentKey as typeof key3;

      if (isPlainObject(value)) {
        return createFields<Model>(value, key1, key2, typedKey);
      } else {
        return field<Model, typeof key1, typeof key2, typeof key3>(
          [key1, key2, typedKey],
          value,
        );
      }
    } else if (key1) {
      const typedKey = currentKey as typeof key2;

      if (isPlainObject(value)) {
        return createFields<Model>(value, key1, typedKey);
      } else {
        return field<Model, typeof key1, typeof key2>([key1, typedKey], value);
      }
    } else {
      const typedKey = currentKey as typeof key1;
      if (isPlainObject(value)) {
        return createFields<Model>(value, typedKey);
      } else {
        return field<Model, typeof key1>(typedKey, value);
      }
    }
  });
}

Here is the isPlainObject helper function:

export const isPlainObject = (val: unknown) =>
  typeof val === 'object' &&
  val !== null &&
  !(val instanceof Date) &&
  !Array.isArray(val);
kossnocorp commented 1 year ago

Hey, thank you so much for the detailed suggestion. I love the idea, even though I never encountered the need to do that.

There's a problem that requires a detailed explanation, so sorry for the long response.

I'm currently working on a new version of Typesaurus, and one of my goals for me was to ensure schema consistency. The problem with partial updates is that they might easily spoil the data. For example, for the given interface:

interface Organization {
  counters?: {
    drafts: number
    scheduled: number
    published: number
  }
}

...such a patch will cause data inconsistency because counters might be undefined:

patch({
  counters: {
    published: 123
  }
})

I managed to write types that ensure that every single field on the page is "safe to update":

db.organizations.update(
  orgId,
  ($) => $.field('counters', 'published').set($.increment(1))
  //                         ^^^^^^^^^^^
  // Argument of type 'string' is not assignable to parameter of type 'never'.
)

It would force you to either write types, so every field is optional:

interface Organization {
  counters?: {
    drafts?: number
    scheduled?: number
    published?: number
  }
}

...or get the document first and update it with data:

org.update({
  counters: {
    ...org.data.counters,
    published: 123
  }
})

As you said, it's laborious to write such updates when there's deep nesting, but it ensures data consistency.

Said that, I need to see if it's possible to make patch operation type-safe and if so, I'll be happy to incorporate it. Please let me know if you have ideas on how to approach it or want to contribute.

braaar commented 1 year ago

My implementation generally relies on the assumption that you are working with a document whose fields are all optional, or that you have verified that your input contains any required fields before you run the patch function. So my example was inaccurate. I should have added ? to each field to signify that they were all optional.

But I don't think it needs to be like that. If the model has some required fields and you try to pass an input object of a type that is missing some of the required fields, you would get a type error, no?

Should we have some way to tell if a field is required during runtime (when we iterate through Object.entries) to prevent the user from applying a bad patch?