edgedb / edgedb-js

The official TypeScript/JS client library and query builder for EdgeDB
https://edgedb.com
Apache License 2.0
509 stars 62 forks source link

Cast enum string values to actual enum values when returning query results #302

Open tdolsen opened 2 years ago

tdolsen commented 2 years ago

edgedb-js: 0.19.15 server: 1.2 cli: 1.1.0+96c3d69 typescript: 4.6.2

Current behavior

When selecting for enum properties, the query returns the string representation of the enum value.

Assuming this dbschema:

module default {
  scalar type PurchaseStatus extending enum<Pending, Purchased>;
  type Purchase {
    required property status -> PurchaseStatus;
  }
}

And given the following select expression:

e.select(e.Purchase, () => ({
  id: true,
  status: true,
}))

The query returns this type:

{
  id: string;
  status: "Pending" | "Purchased";
}[]

With the value of status being set to either "Pending" or "Purchase" strings.

Expected behavior

Return the actual enum value.

Querying the same expression, the returned type should be:

{
  id: string;
  status: e.PurchaseStatus;
}[]

With the value of status being set to either e.PurchaseStatus.Pending or e.PurchasaeStatus.Purchased, avoiding string values entirely.

Details

When using the generated types and helpers, (that ensures beautiful end-to-end type safety in all other regards,) it is extremely frustrating not being able to consistently use the enums defined in the schema. As it is now I can't rely on type matching and casting working, and value validation requires extra handling.

I especially painfully wrestled with this trying to type cast select results containing enums to their respective data transfer objects. The DTOs of course implementing a perfect shape match, with exactly the same enums as exported to the generated code.

So my options seems limited to:

  1. Surgically patch each select result containing an enum property. (Tedious.)
  2. Write a custom abstraction wrapper around e.select /expr.run. (Hah - as if!)
  3. Blindly force type casting, don't do any validation, and hope nothing breaks in the future. (Look ma', no hands!)
  4. Drop enums all together and use unions instead. (And lose out on the benefits.)

Needless to say really, but none of these "solutions" have any appeal at all.

As I understand it it's the underlying engine of the server core that dictates this behavior, and edgedb-js can't do anything with how the values are returned from the server. (Maybe the issue should be raised in edgedb/edgedb as well.)

However, considering that all the types and values are readily available in the generated code, shouldn't it be possible to handle it in the client library, converting the values/types before returning the result?

izakfilmalter commented 1 year ago

This is driving me nuts. I am having to cast the property every time I use it since I have built around expecting an enum. I think I would be fine if it was just a union type every time. We could something like this to basically get the same effect as an Enum:

const ALL_SUITS = ['hearts', 'diamonds', 'spades', 'clubs'] as const;
type SuitTuple = typeof ALL_SUITS; // readonly ['hearts', 'diamonds', 'spades', 'clubs']
type Suit = SuitTuple[number];  // "hearts" | "diamonds" | "spades" | "clubs"

It does split it into two which is maybe not as nice, but you still do programatic things with ALL_SUITS eg looping. You can also do discrimination with Suit eg:

export type ADTMember<ADT, Key extends string, Type extends string> = Extract<
  ADT,
  { [k in Key]: Type }
>

type Matchers<Key extends string, ADT extends { [k in Key]: string }, Out> = {
  [D in ADT[Key]]: (v: ADTMember<ADT, Key, D>) => Out
}

export const matchOn =
  <K extends string>(key: K) =>
  <ADT extends { [k in K]: string }, Z>(matchObj: Matchers<K, ADT, Z>) =>
  (v: ADT) =>
    matchObj[v[key]](v as ADTMember<ADT, K, typeof v[K]>)

type result = {
    suit: Suit
}

const matchSuit = matchOn('suit')

const displaySuit = (result: result) => matchSuit({
  hearts: () => 'hearts',
  diamonds: () => 'diamonds',
  spades: () => 'spaded',
  clubs: () => 'clubs',
})(result)
tdolsen commented 1 year ago

I don't know if this is still applicable, but my solution this spring was to do the following in my package.json's script declarations:

{
    "scripts": {
        "generate:edgeql": "yarn edgeql-js --output-dir ./generated/edgeql --target cjs",
        "postgenerate:edgeql": "yarn format:generated:edgeql && yarn postgenerate:edgeql:enum-patch",
        "postgenerate:edgeql:enum-patch": "replace-in-files --regex='(\\t*)export enum (\\w+)' --replacement='$1export type $2 = _$2 | `$${_$2}`;\\n$1export enum _$2' ./generated/edgeql/types.d.ts",
        "format:generated:edgeql": "prettier --write \"generated/edgeql/**/*.{ts,js}\"",
        ...
    },
    "dependencies": {
        "edgedb": "^0.19.15",
    },
    "devDependencies": {
        "prettier": "^2.6.0",
        "replace-in-files-cli": "^2.0.0",
        ...
    }
}

Doing so I ended up with actually exported enums I could use, together with the standard const declarations edgeql-js outputted.

Note that my patch hinges on the generated code being formatted with my prettier ruleset, using tabs for indentation. Mileage my vary.