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

Add context to relay resolvers #4704

Closed
wants to merge 35 commits into from

Conversation

Markionium
Copy link
Contributor

This is a draft PR to implement an approach mentioned in #4000.

I've currently put the resolverContext on the Store instead of the Environment. It seems that the Store might be a better place than the Environment?

If defined on the environment the context would need to go from the environment into the store down to the reader? Since a store could be used by different environments if the resolverContext would be on the environment that might cause more issues than it being on the store?

The following is an example of how this would be used.

One would provide the context as follows.

const store = new LiveResolverStore(source, {
    resolverContext: {
      counter: createObservableCounter(),
      world: "World!"
    },
  });

Then the resolvers could use the context similar to readFragment

/**
 * @RelayResolver Query.hello_context: String
 *
 * Say `Hello, ${world}!`
 */
import {resolverContext} from '../../ResolverFragments';

function hello_context(): string {
  const world = resolverContext<{world: string}>().world;
  return `Hello, ${world}!`;
}
/**
 * @RelayResolver Query.hello_world_with_context: String
 * @live
 *
 * Say `Hello ${world}!`
 */
function hello_world_with_context(): LiveState<string> {
  const dependency = resolverContext<{greeting: string}>();

  return {
    read() {
      return `Hello ${dependency.greeting}!`;
    },

    subscribe(callback) {
      return () => {
        // no-op
      };
    },
  };
}

@facebook-github-bot
Copy link
Contributor

Hi @Markionium!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@captbaritone
Copy link
Contributor

Hey @Markionium thanks for picking this up!

I agree that attaching the context to the store is the right choice.

However, rather than accessing the context via a magic function, my preference would be that the context object get passed in as the third argument to the resolver function. This would keep alignment with a traditional graphql-js style resolver function.

/**
 * @RelayResolver User.bestFriend: User
 */
export function myResolver(user, _, ctx) {
    return ctx.userById(user.bestFriendId);
}

In terms of type safety, I think we can allow users to provide a module/export name in the compiler config for the project. We can then include that type in our generated type assertions (note that these are not yet built for TypeScript, but it should be pretty easy to add if you are interested in another project).

You still need to ensure your compiler config and the store you create actually match, but that's something you can get right once and in one place so probably an okay end state.

Beyond that we are also exploring a more light-weight way to define resolvers using an approach similar to Grats. In that world it could be even simpler, and work similar to how it works in Grats (see the docs here).

@captbaritone
Copy link
Contributor

Connecting the dots. @alloy and I have been discussing this a bit more on this Gist: https://gist.github.com/alloy/f0c9c90ff7a28f3b17850021488979d5

@captbaritone
Copy link
Contributor

Looks like this is getting close, but still in draft. Give me a ping when this is ready for review! Excited to see this!

@drewatk
Copy link
Contributor

drewatk commented Aug 8, 2024

Hey @captbaritone! We're ready for your review here. @Markionium can you please un-mark as draft?

@Markionium Markionium marked this pull request as ready for review August 8, 2024 18:48
_: void,
_: void,
context: LiveResolverContextType,
) => LiveState<?number>);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This _ for both might cause duplicate identifier issues (at least in typescript it would)

image

We could probably just name them key and args in the type. I don't think we need the underscore in types because they won't be marked as unused.

Comment on lines 628 to 655
let context_import = if resolver_metadata.live {
match &typegen_context
.project_config
.typegen_config
.live_resolver_context_type
{
Some(LiveResolverContextTypeInput::Path(context_import)) => {
Some(LiveResolverContextType {
name: context_import.name.clone().intern(),
import_path: typegen_context.project_config.js_module_import_identifier(
&typegen_context.project_config.artifact_path_for_definition(
typegen_context.definition_source_location,
),
&PathBuf::from(&context_import.path),
),
})
}
Some(LiveResolverContextTypeInput::Package(context_import)) => {
Some(LiveResolverContextType {
name: context_import.name.clone().intern(),
import_path: context_import.package.clone().intern(),
})
}
None => None,
}
} else {
None
};
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since only the import_path is different we could do something like this (Note I didn't run this)

I think it doesn't matter too much but it might make it a bit clearer.

Suggested change
let context_import = if resolver_metadata.live {
match &typegen_context
.project_config
.typegen_config
.live_resolver_context_type
{
Some(LiveResolverContextTypeInput::Path(context_import)) => {
Some(LiveResolverContextType {
name: context_import.name.clone().intern(),
import_path: typegen_context.project_config.js_module_import_identifier(
&typegen_context.project_config.artifact_path_for_definition(
typegen_context.definition_source_location,
),
&PathBuf::from(&context_import.path),
),
})
}
Some(LiveResolverContextTypeInput::Package(context_import)) => {
Some(LiveResolverContextType {
name: context_import.name.clone().intern(),
import_path: context_import.package.clone().intern(),
})
}
None => None,
}
} else {
None
};
let context_import = if resolver_metadata.live {
match &typegen_context
.project_config
.typegen_config
.live_resolver_context_type
{
Some(context_type) => {
let import_path = match context_type {
LiveResolverContextTypeInput::Path(context_import) => {
typegen_context.project_config.js_module_import_identifier(
&typegen_context.project_config.artifact_path_for_definition(
typegen_context.definition_source_location,
),
&PathBuf::from(&context_import.path),
)
}
LiveResolverContextTypeInput::Package(context_import) => {
context_import.package.clone().intern()
}
};
Some(LiveResolverContextType {
name: context_type.name.clone().intern(),
import_path,
})
},
None => None,
}
} else {
None
};

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this work because in the Some(LiveResolverContextType { I get an error no field nameon type&LiveResolverContextTypeInputbecause at that point the type isn't specific enough to know that both types share thename` field

Comment on lines +482 to +486
let void_type = match typegen_context.project_config.typegen_config.language {
TypegenLanguage::Flow | TypegenLanguage::JavaScript => AST::RawType(intern!("void")),
TypegenLanguage::TypeScript => AST::RawType(intern!("undefined")),
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In javascript, we probably don't want to show any types at all? Or we could have TS docblock types, but in that case perhaps it would make more sense to align JS with TS?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. This might be caught later in the writer for JS also just by never writing any types but I'm not sure about that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to add a test which confirms what happens here. Could be an integration test or a typegen test

store: new LiveResolverStore(RelayRecordSource.create(), {
gcReleaseBufferSize: 0,
liveResolverContext: {
greeting: {myHello: 'Hello Allemaal!'},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Markionium I zie you 😄

try {
const resolverFunctionArgs = [];
if (field.fragment != null) {
resolverFunctionArgs.push(fragmentKey);
} else {
// Set first argument to `null` in case we have resolver context
Copy link
Contributor

@alloy alloy Aug 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The code is pushing undefined, not null. What gives?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot to change the comment, but I changed it to undefined to keep consistent with the : undefined we will push for the args value

*/
export type LiveResolverContext = {
[key: string]: mixed,
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to insist on this being of any particular type? Unsure of the Flow semantics, perhaps this typing matches any form of JS object?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I think context should be able to be whatever value the user wants to provide. I don't think Relay needs to have an opinion. A common case might be a Redux store.


'use strict';

import type {LiveResolverContextType} from '../../../mutations/__tests__/LiveResolverContextType';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: For clarity sake, I think it would be good to rename this type to self-document that it's a test fixture representing the host's context typing, not a generic Relay provided context typing.

@@ -57,7 +57,8 @@
"relay_resolver_enable_interface_output_type": { "kind": "enabled" }
},
"language": "flow",
"experimentalEmitSemanticNullabilityTypes": true
"experimentalEmitSemanticNullabilityTypes": true,
"liveResolverContextType": { "name": "LiveResolverContextType", "path": "packages/relay-runtime/mutations/__tests__/LiveResolverContextType" }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need another project that exercises the package module specifier variant?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly? As far as I could find there was only one project config for all tests

yarn-error.log Outdated
@@ -0,0 +1,7442 @@
Arguments:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoopsie, accidental check-in

@@ -178,7 +178,10 @@ pub(crate) fn write_operation_type_exports_section(
write_import_actor_change_point(actor_change_status, writer)?;
runtime_imports.write_runtime_imports(writer)?;
write_fragment_imports(typegen_context, None, encountered_fragments, writer)?;
write_relay_resolver_imports(imported_resolvers, writer)?;
// TODO: Add proper support for Resolver type generation in typescript
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is follow-up work for us, right? Can you link a WI/issue?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#4772 Added

@@ -449,7 +452,11 @@ pub(crate) fn write_fragment_type_exports_section(
write_custom_scalar_imports(custom_scalars, writer)?;

runtime_imports.write_runtime_imports(writer)?;
write_relay_resolver_imports(imported_resolvers, writer)?;

// TODO: Add proper support for Resolver type generation in typescript
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#4772 Added

@drewatk
Copy link
Contributor

drewatk commented Aug 14, 2024

@captbaritone @Markionium @alloy Ready for another look here. Addressed all comments and added some fixture tests as well. Feel free to suggest any additional tests you'd like to see.

All rust tests are passing on my local, seems to be failing on ubuntu but not mac in CI.

@drewatk
Copy link
Contributor

drewatk commented Aug 19, 2024

@captbaritone Gentle Ping :)

Copy link
Contributor

@captbaritone captbaritone left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything looks good! Just a few minor nits/fixups and we can land this!

@@ -136,6 +136,13 @@ pub(crate) struct ImportedResolver {
pub resolver_name: ImportedResolverName,
pub resolver_type: AST,
pub import_path: StringKey,
pub context_import: Option<LiveResolverContextType>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub context_import: Option<LiveResolverContextType>,
pub context_import: Option<ResolverContextType>,

}

#[derive(Clone, Copy)]
pub(crate) struct LiveResolverContextType {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub(crate) struct LiveResolverContextType {
pub(crate) struct ResolverContextType {

source,
snapshot.selector,
this._resolverCache,
this._resolverContext || undefined,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
this._resolverContext || undefined,
this._resolverContext || undefined,
Suggested change
this._resolverContext || undefined,
this._resolverContext,

* `resolverContext` is set on the Relay Store.
* This context will be passed as the third argument to the live resolver
*/
export type ResolverContext = Object;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export type ResolverContext = Object;
export type ResolverContext = mixed;

I don't think we need to be opinionated at all as to what your context is.


</TabItem>

<TabItem value="Flow">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Context won't work with the Flow syntax until we do some additional work. Can you replace these Flow tabs with a note saying it's not supported for now?

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

In order to pass a service, or other values to be shared with all resolvers, the `LiveResolverStore` provides a means of passing context. This gets passed to the third argument of all resolvers (live and non-live).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could call out that this is analogous to the context argument used on the server which usually holds things like the database connection: https://graphql.org/learn/execution/#root-fields--resolvers


In a full GraphQL implementation, resolvers would have access to a `context` and `info` argument. These arguments are not available in Relay Resolvers today. Supporting context is something we would like to do in the future, but have not yet implemented.
In a full GraphQL implementation, resolvers would have access to an `info` argument. This argument is not available in Relay Resolvers today.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes!!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a fun change :)

@@ -129,6 +148,10 @@ pub struct TypegenConfig {
/// A map from GraphQL error name to import path, example:
/// {"name:: "MyErrorName", "path": "../src/MyError"}
pub custom_error_type: Option<CustomTypeImport>,

// Indicates the type to import and use as the context for live resolvers.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you use /// here instead of // it will get picked up as part of the description in the json schema we generate. Can use that here and add similar comments to the ResolverContextTypeInput type and its variants?

@captbaritone
Copy link
Contributor

Rust test failures on CI are unrelated. We have an ongoing issue where it is running out of disk space.

@facebook-github-bot
Copy link
Contributor

@captbaritone has imported this pull request. If you are a Meta employee, you can view this diff on Phabricator.

@@ -1468,6 +1468,48 @@
"default": false,
"type": "boolean"
},
"resolverContextType": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you rerun the Rust tests with the flag to update snapshots this should update to include the description

Comment on lines 70 to 74
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
pub struct ResolverContextTypeInputPath {
pub name: StringKey,
pub path: PathBuf,
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
pub struct ResolverContextTypeInputPath {
pub name: StringKey,
pub path: PathBuf,
}
/// Specifies how Relay can import the Resolver context type from a path
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
pub struct ResolverContextTypeInputPath {
/// The name under which the type is exported from the module
pub name: StringKey,
/// The path to the module
pub path: PathBuf,
}

Can we also clarify what the path is relative to?

Comment on lines 76 to 80
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
pub struct ResolverContextTypeInputPackage {
pub name: StringKey,
pub package: StringKey,
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
pub struct ResolverContextTypeInputPackage {
pub name: StringKey,
pub package: StringKey,
}
/// Specifies how Relay can import the Resolver context type from a named package
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
pub struct ResolverContextTypeInputPackage {
/// The name under which the type is exported from the package
pub name: StringKey,
/// The name of the package
pub package: StringKey,
}

value: AST::RawType(format!("{fragment_name}$key").intern()),
read_only: false,
optional: false,
});
}
} else {
resolver_arguments.push(KeyValuePairProp {
key: "key".intern(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
key: "key".intern(),
key: intern!("key".intern),

Package(ResolverContextTypeInputPackage),
}

/// Specifies how Relay can import the Resolver context type from a path
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

@facebook-github-bot
Copy link
Contributor

@captbaritone has imported this pull request. If you are a Meta employee, you can view this diff on Phabricator.

@captbaritone
Copy link
Contributor

When trying to land this internally I found that it was going to be very difficult to rollout this change safely if we change how resolvers that don't read a parent object or rootFragment get called. So, I've updated the diff a bit internally to go back to the previous behavior where the first argument is omitted if there is no rootFragment and the resolver is not on a model type. I've updated the docs to match.

@facebook-github-bot
Copy link
Contributor

@captbaritone merged this pull request in 2973a0f.

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

Successfully merging this pull request may close these issues.

6 participants