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 6 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

Yeah, we definitely want to allow composition of e.params for exactly the reasons you've laid out here. Design-wise, I think we'd like to make this native to e.params and off-the-cuff, I don't think there is anything stopping us from doing that.

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

Yeah, feel free to take a swing at updating e.params to have this kind of auto-currying behavior, ideally while maintaining the existing API in a backwards compatible way.

scotttrinh avatar Jun 02 '25 13:06 scotttrinh