Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hooks extension improvement proposal #2

Open
MaxBolotnyi opened this issue Apr 30, 2024 · 4 comments
Open

Hooks extension improvement proposal #2

MaxBolotnyi opened this issue Apr 30, 2024 · 4 comments

Comments

@MaxBolotnyi
Copy link

Hi,
First of all, thank you for all the effort with that project. The idea of bringing prisma to react-native and expo is breathtaking and promising.

The current implementation of the react hooks extension makes a query to a database on a per-hook basis, which could lead to a redundant query waterfall in a scenario where the same hook/query is utilized in multiple components at the same time. Would you consider implementing the shared cache for the same queries 'batching' the same calls into a single one sharing a common RAM cache over all the hooks that read that same query?
The TanStack Query uses a similar approach for batching and sharing the cache over its query hooks.

Also, would you be open to accepting the PR for that implementation to help you?

@MaxBolotnyi MaxBolotnyi changed the title Hooks extention imrovement proposal Hooks extension improvement proposal Apr 30, 2024
@sorenbs
Copy link
Member

sorenbs commented Apr 30, 2024

Hey Max - thank you for your interest!

What you describe is exactly what we have planned. Our goal for the first release is to get an API that we feel good about in the hands of developers, such that we can gather feedback and lock-down a final API. For example, it is convention that hooks be free functions ala useQuery() rather than properties on an object ala prisma.user.findFirst() - is that a real concern that we should address by changing the API, or is the commonality with the normal Prisma API enough of a benefit to break this convention? That's the kinds of questions we seek to get answered by the end of Q2.

Following that, we want to focus on optimizations. a shared cache as you propose is an important optimization. Another one is reducing the size of the React Native library. It's currently 25 mb, which is fine for gathering initial feedback, but not really acceptable for broader use in production apps.

We generally welcome contributions, and would love to see a pr for the shared cache implementation. We would need extensive test coverage before merging it, and we can collaborate on that aspect if you like. The current code base is not well tested, so we'll need to improve here before we introduce additional complexity.

@MaxBolotnyi
Copy link
Author

Hi Soren, thank for the valuable reply!

I would be glad to collaborate and roll out the PR fot the proposed cache implementation. Could you please point me out to any documentation regarding the flow that would fit you best, or would it be best just to roll out the draft version of it and start from there?

P.S. Regarding the question you raised in the first paragraph, I would personally put my vote on the object property implementation and keep it aligned with the usual Prisma API, but that's just my humble opinion, though

@sorenbs
Copy link
Member

sorenbs commented May 25, 2024

P.S. Regarding the question you raised in the first paragraph, I would personally put my vote on the object property implementation and keep it aligned with the usual Prisma API, but that's just my humble opinion, though

Thank you for that feedback!

If you simply fork the repo and prepare a pull request, then we can take it from there. Let me know if you need any help. You are welcome to ping me on Twitter if I am slow to reply here :-)

Also, I just announced this at app.js in Krakow. You can find the blogpost here: https://www.prisma.io/blog/bringing-prisma-orm-to-react-native-and-expo

@josemak25
Copy link

@MaxBolotnyi @sorenbs

I came across your discussion about the hook extension improvement and the idea of implementing a shared cache for queries to address the redundant query waterfall issue. I’ve been facing a similar challenge in my own project and have developed a solution that leverages a shared cache manager. This manager batches queries and shares a common in-memory cache across all hook calls for the same query, effectively preventing unnecessary re-fetches—especially in scenarios where multiple components are consuming the same query.

Below is a snippet of the cache manager and extension, which includes a debouncing mechanism to ensure that only the last query is executed when multiple updates to the same table occur in quick succession.

Cache Manager Implementation

// cache.ts file

import { Prisma } from "@prisma/client";

export class PrismaCacheManager {
  private queue = new Map<Prisma.ModelName, NodeJS.Timeout>();
  private listeners = new Map<Prisma.ModelName, Set<() => void>>();
  private cache = new Map<Prisma.ModelName, Record<string, { data: any }>>();

  private get(model: Prisma.ModelName) {
    return this.cache.get(model);
  }

  set(model: Prisma.ModelName, key: string, data: any) {
    const cache = this.get(model);
    this.cache.set(model, { ...cache, [key]: { data } });
  }

  getSnapshot(model: Prisma.ModelName, key: string) {
    const cache = this.get(model) || {};
    return cache[key]?.data;
  }

  exist(model: Prisma.ModelName, key: string) {
    const cache = this.get(model) || {};
    return Boolean(cache[key]);
  }

  subscribe(model: Prisma.ModelName, listener: () => Promise<void>) {
    if (!this.listeners.has(model)) {
      this.listeners.set(model, new Set());
    }

    const listeners = this.listeners.get(model);
    listeners?.add(listener);

    return () => listeners?.delete(listener);
  }

  notifySubscribers(model: Prisma.ModelName) {
    if (this.queue.has(model)) {
      clearTimeout(this.queue.get(model)!);
    }

    const timer = setTimeout(() => {
      const listeners = this.listeners.get(model);
      listeners?.forEach((listener) => listener());
    }, 100); // 100ms debounce, we can adjust this value as needed

    this.queue.set(model, timer);
  }
}

Prisma Extension with Shared Cache

// database.ts

import "@prisma/react-native";
import { PrismaClient, Prisma } from "@prisma/client/react-native";
import { useEffect, useRef, useSyncExternalStore } from "react";

import { PrismaCacheManager } from "./cache";

const client = new PrismaClient();

client.$applyPendingMigrations();
const cache = new PrismaCacheManager();

// A custom hook for reading data from cache and subscribing to updates
function usePrismaCache<T>(model: Prisma.ModelName, key: string, queryFn: () => Promise<T>) {
  const listener = useRef((callback: () => void) => async () => {
    const data = await queryFn();
    cache.set(model, key, data);
    callback();
  }).current;

  const store = useSyncExternalStore<Awaited<T>>(
    (cb) => cache.subscribe(model, listener(cb)),
    () => cache.getSnapshot(model, key)
  );

  useEffect(() => {
    if (!cache.exist(model, key)) {
      cache.notifySubscribers(model);
    }
  }, []);

  return store;
}

export const cachedReactiveHooksExtension = () =>
  Prisma.defineExtension((client) => {
    return client.$extends({
      name: "prisma-cached-reactive-hooks-extension",

      model: {
        $allModels: {
          useFindMany<T, A>(
            this: T,
            args?: Prisma.Exact<A, Prisma.Args<T, "findMany">>
          ): Prisma.Result<T, A, "findMany"> {
            const ctx = Prisma.getExtensionContext(this);
            const table = ctx.$name as Prisma.ModelName;
            const model = (ctx.$parent as any)[table];
            const key = `${table} :: findMany :: ${JSON.stringify(args)}`;

            const queryFn = (): Promise<Prisma.Result<T, A, "findMany">> => model.findMany(args);

            return usePrismaCache(table, key, queryFn);
          },

          // Repeat and use the usePrismaCache for all other hooks passing the table, args key and query function
          // REPEAT FOR --> useFindUnique, useFindFirst, useAggregate and useGroupBy

          async create<T, A>(
            this: T,
            args?: Prisma.Exact<A, Prisma.Args<T, "create">>
          ): Promise<Prisma.Result<T, A, "create">> {
            const ctx = Prisma.getExtensionContext(this);
            const table = ctx.$name as Prisma.ModelName;
            const model = (ctx.$parent as any)[table];
            const prismaPromise = model.create(args);
            const data = await prismaPromise;
            cache.notifySubscribers(table);

            return data;
          },

          // Repeat and call the cache.notifySubscribers for all other prisma CRUD methods passing the table name
          // REPEAT FOR --> delete, createMany, deleteMany, update, updateMany and upsert
        },
      },
    });
  });

export const prisma = client.$extends(cachedReactiveHooksExtension());

Example Usage

const App = () => {
 //This only reads once from the database and subsequently reads from the cache and only updates if cache changes
  const users = prisma.user.useFindMany({
    where: { active: true },
  });

  return (
    <div>
      {users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
};

I'm really excited about the progress Prisma is making in the React Native space. This shared cache solution can significantly improve performance by reducing redundant queries, and I'd love to contribute by raising a PR. I'm also open to discussing any improvements to help make this widely adopted by others in the community.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants