From 9ccab153b95b0e01bad297ecb3ccffb9b203aa27 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Wed, 27 Dec 2023 09:49:00 +0100 Subject: [PATCH] Input unions (#481) * support input unions * not needed * the correct fork commit * changelog * add test case for oneOf fields * docs for input unions --- CHANGELOG.md | 1 + packages/relay | 2 +- .../__tests__/Test_inputUnion-tests.js | 40 +++++ .../__tests__/Test_inputUnion.res | 41 +++++ .../RelaySchemaAssets_graphql.res | 34 ++++ .../TestInputUnionQuery_graphql.res | 149 ++++++++++++++++++ .../rescript-relay/__tests__/schema.graphql | 15 ++ .../rescript-relay/__tests__/utils-tests.js | 29 ++++ packages/rescript-relay/src/utils.js | 14 +- .../docs/input-unions.md | 82 ++++++++++ rescript-relay-documentation/sidebars.js | 1 + 11 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 packages/rescript-relay/__tests__/Test_inputUnion-tests.js create mode 100644 packages/rescript-relay/__tests__/Test_inputUnion.res create mode 100644 packages/rescript-relay/__tests__/__generated__/TestInputUnionQuery_graphql.res create mode 100644 rescript-relay-documentation/docs/input-unions.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fd3727a1..f739fb09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Emit `@live` for union variant cases to prevent false positives in unions. - Set `@rescript/tools` version explicitly. +- First class support for input unions, leveraging the `@oneOf` directive. # 3.0.0-alpha.4 diff --git a/packages/relay b/packages/relay index a918105b..c4ae8350 160000 --- a/packages/relay +++ b/packages/relay @@ -1 +1 @@ -Subproject commit a918105b282d5cd56969a0c49aa5efa2c4adc908 +Subproject commit c4ae83503c5d43d56e4c5a7e8083b39e513e3bed diff --git a/packages/rescript-relay/__tests__/Test_inputUnion-tests.js b/packages/rescript-relay/__tests__/Test_inputUnion-tests.js new file mode 100644 index 00000000..de30119f --- /dev/null +++ b/packages/rescript-relay/__tests__/Test_inputUnion-tests.js @@ -0,0 +1,40 @@ +require("@testing-library/jest-dom/extend-expect"); +const t = require("@testing-library/react"); +const React = require("react"); +const queryMock = require("./queryMock"); + +const { test_inputUnion } = require("./Test_inputUnion.bs"); + +describe("Query", () => { + test("conversion of input unions work", async () => { + queryMock.mockQuery({ + name: "TestInputUnionQuery", + variables: { + location: { + byAddress: { + city: "City", + }, + }, + }, + data: { + findByLocation: "Got it", + }, + }); + + queryMock.mockQuery({ + name: "TestInputUnionQuery", + variables: { + location: { + byId: "", + }, + }, + data: { + findByLocation: "Got ID", + }, + }); + + t.render(test_inputUnion()); + await t.screen.findByText("Got it"); + await t.screen.findByText("Got ID"); + }); +}); diff --git a/packages/rescript-relay/__tests__/Test_inputUnion.res b/packages/rescript-relay/__tests__/Test_inputUnion.res new file mode 100644 index 00000000..141f15c1 --- /dev/null +++ b/packages/rescript-relay/__tests__/Test_inputUnion.res @@ -0,0 +1,41 @@ +module Query = %relay(` + query TestInputUnionQuery($location: Location!) { + findByLocation(location: $location) + } +`) + +module Test = { + @react.component + let make = () => { + let data = Query.use( + ~variables={ + location: ByAddress({city: "City"}), + }, + ) + + let data2 = Query.use( + ~variables={ + location: ById(""), + }, + ) + + <> +
{React.string(data.findByLocation->Belt.Option.getWithDefault("-"))}
+
{React.string(data2.findByLocation->Belt.Option.getWithDefault("-"))}
+ + } +} + +@live +let test_inputUnion = () => { + let network = RescriptRelay.Network.makePromiseBased(~fetchFunction=RelayEnv.fetchQuery) + + let environment = RescriptRelay.Environment.make( + ~network, + ~store=RescriptRelay.Store.make(~source=RescriptRelay.RecordSource.make()), + ) + + + + +} diff --git a/packages/rescript-relay/__tests__/__generated__/RelaySchemaAssets_graphql.res b/packages/rescript-relay/__tests__/__generated__/RelaySchemaAssets_graphql.res index f8f32576..e97944fe 100644 --- a/packages/rescript-relay/__tests__/__generated__/RelaySchemaAssets_graphql.res +++ b/packages/rescript-relay/__tests__/__generated__/RelaySchemaAssets_graphql.res @@ -161,3 +161,37 @@ and input_PesticideListSearchInput_nullable = { skip: int, take: int, } + +@live +and input_ByAddress = { + city: string, +} + +@live +and input_ByAddress_nullable = { + city: string, +} + +@live +and input_ByLoc = { + lat: float, +} + +@live +and input_ByLoc_nullable = { + lat: float, +} + +@live +@tag("__$inputUnion") +and input_Location = +| @as("byAddress") ByAddress(input_ByAddress) +| @as("byLoc") ByLoc(input_ByLoc) +| @as("byId") ById(string) + +@live +@tag("__$inputUnion") +and input_Location_nullable = +| @as("byAddress") ByAddress(input_ByAddress_nullable) +| @as("byLoc") ByLoc(input_ByLoc_nullable) +| @as("byId") ById(string) diff --git a/packages/rescript-relay/__tests__/__generated__/TestInputUnionQuery_graphql.res b/packages/rescript-relay/__tests__/__generated__/TestInputUnionQuery_graphql.res new file mode 100644 index 00000000..07887bea --- /dev/null +++ b/packages/rescript-relay/__tests__/__generated__/TestInputUnionQuery_graphql.res @@ -0,0 +1,149 @@ +/* @sourceLoc Test_inputUnion.res */ +/* @generated */ +%%raw("/* @generated */") +module Types = { + @@warning("-30") + + @live type location = RelaySchemaAssets_graphql.input_Location + @live type byAddress = RelaySchemaAssets_graphql.input_ByAddress + @live type byLoc = RelaySchemaAssets_graphql.input_ByLoc + type response = { + findByLocation: option, + } + @live + type rawResponse = response + @live + type variables = { + location: location, + } + @live + type refetchVariables = { + location: option, + } + @live let makeRefetchVariables = ( + ~location=?, + ): refetchVariables => { + location: location + } + +} + +module Internal = { + @live + let variablesConverter: Js.Dict.t>> = %raw( + json`{"location":{"byLoc":{"r":"byLoc"},"byAddress":{"r":"byAddress"}},"byAddress":{},"byLoc":{},"__root":{"location":{"r":"location"}}}` + ) + @live + let variablesConverterMap = () + @live + let convertVariables = v => v->RescriptRelay.convertObj( + variablesConverter, + variablesConverterMap, + Js.undefined + ) + @live + type wrapResponseRaw + @live + let wrapResponseConverter: Js.Dict.t>> = %raw( + json`{}` + ) + @live + let wrapResponseConverterMap = () + @live + let convertWrapResponse = v => v->RescriptRelay.convertObj( + wrapResponseConverter, + wrapResponseConverterMap, + Js.null + ) + @live + type responseRaw + @live + let responseConverter: Js.Dict.t>> = %raw( + json`{}` + ) + @live + let responseConverterMap = () + @live + let convertResponse = v => v->RescriptRelay.convertObj( + responseConverter, + responseConverterMap, + Js.undefined + ) + type wrapRawResponseRaw = wrapResponseRaw + @live + let convertWrapRawResponse = convertWrapResponse + type rawResponseRaw = responseRaw + @live + let convertRawResponse = convertResponse +} + +type queryRef + +module Utils = { + @@warning("-33") + open Types +} + +type relayOperationNode +type operationType = RescriptRelay.queryNode + + +let node: operationType = %raw(json` (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "location" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "location", + "variableName": "location" + } + ], + "kind": "ScalarField", + "name": "findByLocation", + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "TestInputUnionQuery", + "selections": (v1/*: any*/), + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "TestInputUnionQuery", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "53a5a8069244d58a20a58e55c9f98f2e", + "id": null, + "metadata": {}, + "name": "TestInputUnionQuery", + "operationKind": "query", + "text": "query TestInputUnionQuery(\n $location: Location!\n) {\n findByLocation(location: $location)\n}\n" + } +}; +})() `) + +include RescriptRelay.MakeLoadQuery({ + type variables = Types.variables + type loadedQueryRef = queryRef + type response = Types.response + type node = relayOperationNode + let query = node + let convertVariables = Internal.convertVariables +}); diff --git a/packages/rescript-relay/__tests__/schema.graphql b/packages/rescript-relay/__tests__/schema.graphql index 4448ed0f..5c50a10f 100644 --- a/packages/rescript-relay/__tests__/schema.graphql +++ b/packages/rescript-relay/__tests__/schema.graphql @@ -206,6 +206,7 @@ type Query { ): UserConnection search(input: SearchInput!): String searchPesticie(input: PesticideListSearchInput!): String + findByLocation(location: Location!): String } type Mutation { @@ -223,3 +224,17 @@ type Mutation { type Subscription { userUpdated(id: ID!): UserUpdatedPayload } + +input ByAddress { + city: String! +} + +input ByLoc { + lat: Float! +} + +input Location @oneOf { + byAddress: ByAddress + byLoc: ByLoc + byId: ID +} diff --git a/packages/rescript-relay/__tests__/utils-tests.js b/packages/rescript-relay/__tests__/utils-tests.js index e6644c55..6fd9f943 100644 --- a/packages/rescript-relay/__tests__/utils-tests.js +++ b/packages/rescript-relay/__tests__/utils-tests.js @@ -452,6 +452,35 @@ describe("conversion", () => { }); }); + it("handles input unions", () => { + expect( + traverser( + { + location: { + __$inputUnion: "byAddress", + _0: { + city: "City", + }, + }, + }, + { + location: { byLoc: { r: "byLoc" }, byAddress: { r: "byAddress" } }, + byAddress: {}, + byLoc: {}, + __root: { location: { r: "location" } }, + }, + {}, + undefined + ) + ).toEqual({ + location: { + byAddress: { + city: "City", + }, + }, + }); + }); + it("handles top level unions on fragments", () => { expect( traverser( diff --git a/packages/rescript-relay/src/utils.js b/packages/rescript-relay/src/utils.js index b0440a9e..6709e84d 100644 --- a/packages/rescript-relay/src/utils.js +++ b/packages/rescript-relay/src/utils.js @@ -16,6 +16,16 @@ function getTypename(v) { } } +function unwrapInputUnion(obj) { + if (obj != null && typeof obj === "object" && "__$inputUnion" in obj) { + return { + [obj["__$inputUnion"]]: obj["_0"], + }; + } + + return obj; +} + /** * Runs on each object in the tree and follows the provided instructions * to apply transforms etc. @@ -128,7 +138,7 @@ function traverse( } if (shouldConvertRootObj) { return traverser( - v, + unwrapInputUnion(v), fullInstructionMap, converters, nullableValue, @@ -198,7 +208,7 @@ function traverse( if (shouldConvertRootObj) { newObj = getNewObj(newObj, currentObj); newObj[key] = traverser( - v, + unwrapInputUnion(v), fullInstructionMap, converters, nullableValue, diff --git a/rescript-relay-documentation/docs/input-unions.md b/rescript-relay-documentation/docs/input-unions.md new file mode 100644 index 00000000..6cd549cf --- /dev/null +++ b/rescript-relay-documentation/docs/input-unions.md @@ -0,0 +1,82 @@ +--- +id: input-unions +title: Input Unions +sidebar_label: Input Unions +--- + +#### Recommended background reading + +- [`@oneOf` input object and fields RFC](https://github.com/graphql/graphql-spec/pull/825) + +## Input Unions in RescriptRelay (available in version >=3) + +Input unions aren't officially a part of the GraphQL spec yet, but there's a propsal that allows you to express them today via the `@oneOf` directive on an input object. RescriptRelay leverages that proposal to give you an ergonomic developer experience using input unions in GraphQL. + +In RescriptRelay, input unions compile to _variants_, just like regular unions do. It looks like this: + +```graphql +input Address { + streetAddress: String! + postalCode: String! + city: String! +} + +input Coordinates { + lat: Float! + lng: Float! +} + +input Location @oneOf { + byAddress: Address + byCoordinates: Coordinates + byId: ID +} + +type Query { + allShops(location: Location!): ShopConnection +} +``` + +This above is an example of searching for shops by a location. That location can be an address, coordinates, or an internal ID for a location. The `@oneOf` directive on `Location` tells us that the server expects _one_ of the fields of the input object to always be set. We call that an _input union_ (even though it's technically not an actual union in the schema, just an input object with a directive). + +Below is an example of what using this input union from RescriptRelay looks like: + +```rescript +// Shops.res +module Query = %relay(` + query ShopsQuery($location: Location!) { + allShops(location: $location) { + ...ShopsResult_shops + } + } +`) + +@react.component +let make = (~lat, ~lng) => { + let data = Query.use(~variables={location: ByCoordinates({lat, lng})}) + + ... +} +``` + +Notice the input union is an actual variant in RescriptRelay. Passing something else to the union is straight forward: + +```rescript +@react.component +let make = (~shopLocationId) => { + let data = Query.use(~variables={location: ById(shopLocationId)}) + + ... +} +``` + +This brings all of the power of variants also to inputs. + +## Summary + +Here's all you need to remember about input unions in RescriptRelay: + +- Input union support is available in RescriptRelay version `>=3`. +- Input unions are defined as input objects with the `@oneOf` directive. +- Input unions are modelled as variants in RescriptRelay. +- There's nothing extra you need to do for input unions to work, they're automatically available on any input object with a `@oneOf` directive. diff --git a/rescript-relay-documentation/sidebars.js b/rescript-relay-documentation/sidebars.js index 5ba8666f..bddb41f9 100755 --- a/rescript-relay-documentation/sidebars.js +++ b/rescript-relay-documentation/sidebars.js @@ -36,6 +36,7 @@ module.exports = { "unions", "interfaces", "input-objects", + "input-unions", "interacting-with-the-store", ], "API Reference": ["api-reference", "relay-environment"],