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

chore: typescript-redux followups #656

Merged
merged 21 commits into from
Apr 22, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e715ab6
Refine / split metadata types (instead of ? -> !)
trevor-scheer Apr 8, 2021
bdad209
getJoins -> getJoinDefinitions
trevor-scheer Apr 8, 2021
b3bb32e
sanitizedServiceNames -> graphNameToEnumValueName
trevor-scheer Apr 8, 2021
afd924c
Address enum sanitization/uniquification comments
trevor-scheer Apr 12, 2021
5bd3f10
Use actual map for GraphMap instead to account for undefined-ness
trevor-scheer Apr 13, 2021
4378987
Clean up usages of printWithReducedWhitespace in favor of stripIgnore…
trevor-scheer Apr 13, 2021
5e40df8
Confirm parsed FieldSets do not have an injected operation
trevor-scheer Apr 15, 2021
e37e2a6
Ensure no FragmentSpreads nested in a FieldSet
trevor-scheer Apr 15, 2021
2aa3cf5
Capture caveats in comments from commit messages
trevor-scheer Apr 15, 2021
fb0f6e9
Remove incorrect nullish coalesce to ownerService
trevor-scheer Apr 15, 2021
5ac14f2
Update ordering of join__Graph enum in test mocks
trevor-scheer Apr 16, 2021
7784276
Invert metadata predicate which was always negated to its opposite
trevor-scheer Apr 21, 2021
4b7d50f
Update expectations comment
trevor-scheer Apr 21, 2021
6c4261b
Create nice helper for working with Maps (mapGetOrSet)
trevor-scheer Apr 21, 2021
524809d
Fix usage of mapGetOrSet
trevor-scheer Apr 21, 2021
208a041
Add clarity to names
trevor-scheer Apr 21, 2021
f85a597
Correct error message
trevor-scheer Apr 21, 2021
2a2c429
Simplify extra } error message
trevor-scheer Apr 21, 2021
a1156a8
Fix remaining accesses to context.graphNameToEnumValueName
trevor-scheer Apr 21, 2021
53e0ad5
Update changelogs
trevor-scheer Apr 22, 2021
fe2c2aa
Merge branch 'main' into trevor/redux-followups
trevor-scheer Apr 22, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions federation-js/src/__tests__/joinSpec.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fixtures } from 'apollo-federation-integration-testsuite';
import { getJoins } from "../joinSpec";
import { getJoinDefinitions } from "../joinSpec";

const questionableNamesRemap = {
accounts: 'ServiceA',
Expand All @@ -17,7 +17,7 @@ const fixturesWithQuestionableServiceNames = fixtures.map((service) => ({

describe('join__Graph enum', () => {
it('correctly uniquifies and sanitizes service names', () => {
const { sanitizedServiceNames } = getJoins(
const { graphNameToEnumValueName } = getJoinDefinitions(
fixturesWithQuestionableServiceNames,
);

Expand All @@ -33,12 +33,12 @@ describe('join__Graph enum', () => {
* (serviceA) tests the edge case of colliding with a name we generated
* (servicea_2_) tests a collision against (documents) post-transformation
*/
expect(sanitizedServiceNames).toMatchObject({
expect(graphNameToEnumValueName).toMatchObject({
'9product*!': '_9PRODUCT__',
ServiceA: 'SERVICEA',
ServiceA: 'SERVICEA_2',
reviews_9: 'REVIEWS_9_',
serviceA: 'SERVICEA_2',
servicea_2: 'SERVICEA_2_',
serviceA: 'SERVICEA_1',
servicea_2: 'SERVICEA_2__1',
servicea_2_: 'SERVICEA_2__2',
});
})
Expand Down
94 changes: 59 additions & 35 deletions federation-js/src/joinSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,59 +27,83 @@ const JoinGraphDirective = new GraphQLDirective({

/**
* Expectations
* 1. Non-Alphanumeric characters are replaced with _ (alphaNumericUnderscoreOnly)
* 2. Numeric first characters are prefixed with _ (noNumericFirstChar)
* 3. Names ending in an underscore followed by numbers `_\d+` are suffixed with _ (noUnderscoreNumericEnding)
* 4. Names are uppercased (toUpper)
* 5. After transformations 1-4, duplicates are suffixed with _{n} where {n} is number of times we've seen the dupe
* 1. The input is first sorted using `String.localeCompare`, so the output is deterministic
* 2. Non-Alphanumeric characters are replaced with _ (alphaNumericUnderscoreOnly)
* 3. Numeric first characters are prefixed with _ (noNumericFirstChar)
* 4. Names ending in an underscore followed by numbers `_\d+` are suffixed with _ (noUnderscoreNumericEnding)
* 5. Names are uppercased (toUpper)
* 6. After transformations 1-4, duplicates are suffixed with _{n} where {n} is number of times we've seen the dupe
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved
*
* Note: Collisions with name's we've generated are also accounted for
*/
function getJoinGraphEnum(serviceList: ServiceDefinition[]) {
// Track whether we've seen a name and how many times
const nameMap: Map<string, number> = new Map();
// Build a map of original service name to generated name
const sanitizedServiceNames: Record<string, string> = Object.create(null);
const sortedServiceList = serviceList
.slice()
.sort((a, b) => a.name.localeCompare(b.name));

function uniquifyAndSanitizeGraphQLName(name: string) {
// Transforms to ensure valid graphql `Name`
const alphaNumericUnderscoreOnly = name.replace(/[^_a-zA-Z0-9]/g, '_');
const noNumericFirstChar = alphaNumericUnderscoreOnly.match(/^[0-9]/)
function sanitizeGraphQLName(name: string) {
// replace all non-word characters (\W). Word chars are _a-zA-Z0-9
const alphaNumericUnderscoreOnly = name.replace(/[\W]/g, '_');
// prefix a digit in the first position with an _
const noNumericFirstChar = alphaNumericUnderscoreOnly.match(/^\d/)
? '_' + alphaNumericUnderscoreOnly
: alphaNumericUnderscoreOnly;
const noUnderscoreNumericEnding = noNumericFirstChar.match(/_[0-9]+$/)
// suffix an underscore + digit in the last position with an _
const noUnderscoreNumericEnding = noNumericFirstChar.match(/_\d+$/)
? noNumericFirstChar + '_'
: noNumericFirstChar;

// toUpper not really necessary but follows convention of enum values
const toUpper = noUnderscoreNumericEnding.toLocaleUpperCase();
return toUpper;
}

// duplicate enum values can occur due to sanitization and must be accounted for
// collect the duplicates in an array so we can uniquify them in a second pass.
const sanitizedNameToServiceDefinitions: Map<
string,
ServiceDefinition[]
> = new Map();
for (const service of sortedServiceList) {
const { name } = service;
const sanitized = sanitizeGraphQLName(name);

const existingEntry = sanitizedNameToServiceDefinitions.get(sanitized);
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved
if (existingEntry) {
sanitizedNameToServiceDefinitions.set(sanitized, [
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved
...existingEntry,
service,
]);
} else {
sanitizedNameToServiceDefinitions.set(sanitized, [service]);
}
}

// Uniquifying post-transform
const nameCount = nameMap.get(toUpper);
if (nameCount) {
// Collision - bump counter by one
nameMap.set(toUpper, nameCount + 1);
const uniquified = `${toUpper}_${nameCount + 1}`;
// We also now need another entry for the name we just generated
nameMap.set(uniquified, 1);
sanitizedServiceNames[name] = uniquified;
return uniquified;
// if no duplicates for a given name, add it as is
// if duplicates exist, append _{n} (index-1) to each duplicate in the array
const generatedNameToServiceDefinition: Record<
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved
string,
ServiceDefinition
> = Object.create(null);
for (const [name, services] of sanitizedNameToServiceDefinitions) {
if (services.length === 1) {
generatedNameToServiceDefinition[name] = services[0];
} else {
nameMap.set(toUpper, 1);
sanitizedServiceNames[name] = toUpper;
return toUpper;
for (const [index, service] of services.entries()) {
generatedNameToServiceDefinition[`${name}_${index + 1}`] = service;
}
}
}

const entries = Object.entries(generatedNameToServiceDefinition);
return {
sanitizedServiceNames,
graphNameToEnumValueName: Object.fromEntries(
entries.map(([name, service]) => [service.name, name]),
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved
),
JoinGraphEnum: new GraphQLEnumType({
name: 'join__Graph',
values: Object.fromEntries(
serviceList.map((service) => [
uniquifyAndSanitizeGraphQLName(service.name),
{ value: service },
]),
entries.map(([name, service]) => [name, { value: service }]),
),
}),
};
Expand Down Expand Up @@ -115,8 +139,8 @@ function getJoinOwnerDirective(JoinGraphEnum: GraphQLEnumType) {
});
}

export function getJoins(serviceList: ServiceDefinition[]) {
const { sanitizedServiceNames, JoinGraphEnum } = getJoinGraphEnum(serviceList);
export function getJoinDefinitions(serviceList: ServiceDefinition[]) {
const { graphNameToEnumValueName, JoinGraphEnum } = getJoinGraphEnum(serviceList);
const JoinFieldDirective = getJoinFieldDirective(JoinGraphEnum);
const JoinOwnerDirective = getJoinOwnerDirective(JoinGraphEnum);

Expand All @@ -135,7 +159,7 @@ export function getJoins(serviceList: ServiceDefinition[]) {
});

return {
sanitizedServiceNames,
graphNameToEnumValueName,
FieldSetScalar,
JoinTypeDirective,
JoinFieldDirective,
Expand Down
18 changes: 9 additions & 9 deletions federation-js/src/service/printSupergraphSdl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
} from 'graphql';
import { Maybe, FederationType, FederationField, ServiceDefinition } from '../composition';
import { CoreDirective } from '../coreSpec';
import { getJoins } from '../joinSpec';
import { getJoinDefinitions } from '../joinSpec';

type Options = {
/**
Expand All @@ -47,7 +47,7 @@ type Options = {
interface PrintingContext {
// Core addition: we need access to a map from serviceName to its corresponding
// sanitized / uniquified enum value `Name` from the `join__Graph` enum
sanitizedServiceNames?: Record<string, string>;
graphNameToEnumValueName?: Record<string, string>;
}

/**
Expand All @@ -72,8 +72,8 @@ export function printSupergraphSdl(
JoinOwnerDirective,
JoinGraphEnum,
JoinGraphDirective,
sanitizedServiceNames,
} = getJoins(serviceList);
graphNameToEnumValueName,
} = getJoinDefinitions(serviceList);

schema = new GraphQLSchema({
...config,
Expand All @@ -89,7 +89,7 @@ export function printSupergraphSdl(
});

const context: PrintingContext = {
sanitizedServiceNames,
graphNameToEnumValueName,
}

return printFilteredSchema(
Expand Down Expand Up @@ -248,7 +248,7 @@ function printTypeJoinDirectives(
const shouldPrintOwner = isObjectType(type);
const joinOwnerString = shouldPrintOwner
? `\n @join__owner(graph: ${
context.sanitizedServiceNames?.[ownerService] ?? ownerService
context.graphNameToEnumValueName?.[ownerService] ?? ownerService
})`
: '';

Expand All @@ -260,7 +260,7 @@ function printTypeJoinDirectives(
.map(
(selections) =>
`\n @join__type(graph: ${
context.sanitizedServiceNames?.[service] ?? service
context.graphNameToEnumValueName?.[service] ?? service
}, key: ${printStringLiteral(printFieldSet(selections))})`,
)
.join(''),
Expand Down Expand Up @@ -398,7 +398,7 @@ function printJoinFieldDirectives(
return (
printed +
`graph: ${
context.sanitizedServiceNames?.[
context.graphNameToEnumValueName?.[
parentType.extensions?.federation.serviceName
] ?? parentType.extensions?.federation.serviceName
})`
Expand All @@ -417,7 +417,7 @@ function printJoinFieldDirectives(

if (serviceName && serviceName.length > 0) {
directiveArgs.push(
`graph: ${context.sanitizedServiceNames?.[serviceName] ?? serviceName}`,
`graph: ${context.graphNameToEnumValueName?.[serviceName] ?? serviceName}`,
);
}

Expand Down
15 changes: 12 additions & 3 deletions query-planner-js/src/buildQueryPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ import {
} from './QueryPlan';
import { getFieldDef, getResponseName } from './utilities/graphql';
import { MultiMap } from './utilities/MultiMap';
import { getFederationMetadataForType, getFederationMetadataForField } from './composedSchema';
import {
getFederationMetadataForType,
getFederationMetadataForField,
isValueTypeMetadata,
} from './composedSchema';
import { DebugLogger } from './utilities/debug';


Expand Down Expand Up @@ -1136,7 +1140,8 @@ export class QueryPlanningContext {
}

getBaseService(parentType: GraphQLObjectType): string | undefined {
return getFederationMetadataForType(parentType)?.graphName;
const type = getFederationMetadataForType(parentType);
return (type && !isValueTypeMetadata(type)) ? type.graphName : undefined;
}

getOwningService(
Expand Down Expand Up @@ -1170,7 +1175,11 @@ export class QueryPlanningContext {
});

for (const possibleType of this.getPossibleTypes(parentType)) {
const keys = getFederationMetadataForType(possibleType)?.keys?.get(serviceName);
const type = getFederationMetadataForType(possibleType);
const keys =
type && !isValueTypeMetadata(type)
? type.keys.get(serviceName)
: undefined;

if (!(keys && keys.length > 0)) continue;

Expand Down
14 changes: 12 additions & 2 deletions query-planner-js/src/composedSchema/buildComposedSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import { MultiMap } from '../utilities/MultiMap';
import {
FederationFieldMetadata,
FederationTypeMetadata,
FederationEntityTypeMetadata,
FieldSet,
GraphMap,
isValueTypeMetadata,
} from './metadata';

export function buildComposedSchema(document: DocumentNode): GraphQLSchema {
Expand Down Expand Up @@ -148,8 +150,11 @@ export function buildComposedSchema(document: DocumentNode): GraphQLSchema {
type.astNode,
);

// The assertion here guarantees the safety of the type cast below
// (typeMetadata as FederationEntityTypeMetadata). Adjustments to this assertion
// should account for this dependency.
assert(
!(typeMetadata.isValueType && typeDirectivesArgs.length >= 1),
!(isValueTypeMetadata(typeMetadata) && typeDirectivesArgs.length >= 1),
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved
`GraphQL type "${type.name}" cannot have a @${typeDirective.name} \
directive without an @${ownerDirective.name} directive`,
);
Expand All @@ -159,7 +164,12 @@ directive without an @${ownerDirective.name} directive`,

const keyFields = parseFieldSet(typeDirectiveArgs['key']);

typeMetadata.keys?.add(graphName, keyFields);
// We know we won't actually be looping here in the case of a value type
// based on the assertion above, but TS is not able to infer that.
(typeMetadata as FederationEntityTypeMetadata).keys.add(
graphName,
keyFields,
);
}

for (const fieldDef of Object.values(type.getFields())) {
Expand Down
23 changes: 19 additions & 4 deletions query-planner-js/src/composedSchema/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,25 @@ export type GraphMap = { [graphName: string]: Graph };
export interface FederationSchemaMetadata {
graphs: GraphMap;
}
export interface FederationTypeMetadata {
graphName?: GraphName;
keys?: MultiMap<GraphName, FieldSet>;
isValueType: boolean;

export type FederationTypeMetadata =
| FederationEntityTypeMetadata
| FederationValueTypeMetadata;

export interface FederationEntityTypeMetadata {
graphName: GraphName;
keys: MultiMap<GraphName, FieldSet>,
isValueType: false;
}

interface FederationValueTypeMetadata {
isValueType: true;
}

export function isValueTypeMetadata(
metadata: FederationTypeMetadata,
): metadata is FederationValueTypeMetadata {
return metadata.isValueType;
}

export interface FederationFieldMetadata {
Expand Down