edgedb-js icon indicating copy to clipboard operation
edgedb-js copied to clipboard

Allow better query composition with Typescript builder

Open vancegillies opened this issue 4 months ago • 1 comments

The TS query builder is amazingly composable as long as you don't use the e.params function because you can't add that query to another, you can only execute the query with the client here is an example from an app I am building

export const getTrackQuery = e.params(
  {
    trackId: e.uuid,
  },
  ({ trackId }) =>
    e.select(e.Track, () => ({
      filter_single: { id: trackId },
    })),
);
export const createRevisionQuery = e.params(
  { trackId: e.uuid, assetKey: e.str },
  (params) => {
    // You can't do this :(
    const t = getTrackQuery({
      trackId: params.trackId,
    });
    // can't even do this if you had a client :(
    const t = getTrackQuery.run({} as Executor,{
      trackId: params.trackId,
    });
    ...
  },
);

This really hurt my brain so I built a wrapper that allowed me to do this

export const currentUserQuery = query((e) => {
  return e.global.current_user;
});

export const getTrackQuery = query.params((e, trackId: string) => {
  return e.select(e.Track, () => ({
    filter_single: { id: trackId },
  }));
});

export const createRevisionQuery = query.params(
  (e, params: { trackId: string; assetKey: string }) => {
    const track = getTrackQuery(params.trackId);

    const name = e.op(
      e.str("Revision "),
      "++",
      e.cast(e.str, e.op(e.count(track.revisions), "+", 1)),
    );

    const asset = e.insert(e.Asset, {
      key: params.assetKey,
    });

    return e.insert(e.Revision, {
      created_by: currentUserQuery(),
      track,
      asset,
      name,
    });
  },
);

which I can also then call like this

// with a client
await createRevisionQuery({ trackId, assetKey }, client);
// partial apply (this is how you compose them)
const runnable = createRevisionQuery({ trackId, assetKey })
// can use the runnable in another query or call run like all other queries
e.run(client)

this is implmented like this

import { Executor } from "gel";
import b, { $infer, TypeSet } from "./builder";

interface QueryBuilder {
  <T extends TypeSet>(fn: (e: typeof b) => T): Query<T>;
}
interface Query<T extends TypeSet = TypeSet> {
  (): T;
  (client: Executor): Promise<$infer<T>>;
}

const _query = ((fn) => {
  return (client) => {
    if (!client) {
      return fn(b);
    }
    // @ts-ignore
    return fn(b).run(client);
  };
}) as QueryBuilder;

interface ParamerterizedQueryBuilder {
  <P, T extends TypeSet = TypeSet>(
    fn: (e: typeof b, params: P) => T,
  ): ParamerterizedQuery<P, T>;
}
interface ParamerterizedQuery<P, T extends TypeSet> {
  (params: P): T;
  (params: P, client: Executor): Promise<$infer<T>>;
}

const _pQuery = ((fn) => {
  return (params, client) => {
    if (!client) {
      return fn(b, params);
    }
    // @ts-ignore
    return fn(b, params).run(client);
  };
}) as ParamerterizedQueryBuilder;

export const query = Object.assign(_query, {
  params: _pQuery,
});

Having a way to do this in the query builder itself would be amazing, the API doesn't need to be the same even just a sibling to run called build or something that just takes the second arg of run of a param query

Happy to help with implementation with this if its something that you think would be useful for everyone

vancegillies avatar Jun 02 '25 12:06 vancegillies