Skip to content
This repository has been archived by the owner on Nov 12, 2023. It is now read-only.

Commit

Permalink
Merge branch '9-implement-field-decorators' into 'master'
Browse files Browse the repository at this point in the history
Resolve "Implement Field Decorators"

Closes #9 and #10

See merge request Mando75/typeorm-graphql-loader!17
  • Loading branch information
Mando75 committed Aug 29, 2020
2 parents ed1ee1d + 0e6c0be commit 22e6a16
Show file tree
Hide file tree
Showing 15 changed files with 617 additions and 53 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Change Log

## [1.3.0]

### Added
A new decorator called `ConfigureLoader` that allows for more control over how entity fields/relations are resolved by the loader. For the initial version, the decorator allows you to ignore or require fields/embeds/relations during query resolution. This is still experimental and may require some hardening. For more information, see the [documentation](https://gql-loader.bmuller.net/globals.html#configureloader)

### Deprecated

`GraphQLQueryBuilder#selectFields`. This was always a rather flaky solution to the problem it was trying to solve. With the release of the configuration decorator, I don't plan on supporting or fixing any bugs with this anymore. Once the decorator API is solidified, this will be removed in a 2.0 release.

## [1.2.0]

### Added
Expand Down
98 changes: 98 additions & 0 deletions src/ConfigureLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import "reflect-metadata";
import { LoaderFieldConfiguration } from "./types";

/**
* Internal keys for mapping entity metadata
* @hidden
*/
const keys = {
IGNORE_FIELD: Symbol("gqlLoader:ignoreField"),
REQUIRED_FIELD: Symbol("gqlLoader:requiredField")
};

/**
* Default args
* @hidden
*/
const defaultLoaderFieldConfiguration: LoaderFieldConfiguration = {
ignore: false,
required: false
};

/**
* An experimental decorator that can be used
* to annotate fields or relations in your TypeORM entities
* and customize the loader resolution logic.
*
* The decorator implementation is still being developed
* and the API may change in the future prior to a 2.0 release.
*
* @example
* ```typescript
* @Entity()
* class Author extends BaseEntity {
*
* // This relation will never be fetched by the dataloader
* @ConfigureLoader({ignore: true})
* @OneToMany()
* books: [Book]
*
* // This relation will always be fetched by the dataloader
* @ConfigureLoader({required: true})
* @OneToOne()
* user: User
* }
* ```
*
* @param options
*/
export const ConfigureLoader = (
options: LoaderFieldConfiguration = defaultLoaderFieldConfiguration
) => {
const { required, ignore } = {
...defaultLoaderFieldConfiguration,
...options
};

return (target: any, propertyKey: string) => {
const ignoreSettings: Map<string, boolean | undefined> =
Reflect.getMetadata(keys.IGNORE_FIELD, target.constructor) ?? new Map();
ignoreSettings.set(propertyKey, ignore);
Reflect.defineMetadata(
keys.IGNORE_FIELD,
ignoreSettings,
target.constructor
);

const requiredSettings: Map<string, boolean | undefined> =
Reflect.getMetadata(keys.REQUIRED_FIELD, target.constructor) ?? new Map();
requiredSettings.set(propertyKey, required);
Reflect.defineMetadata(
keys.REQUIRED_FIELD,
requiredSettings,
target.constructor
);
};
};

/**
* Fetch the required fields from entity metadata
* @hidden
* @param target
*/
export const getLoaderRequiredFields = (
target: any
): Map<string, boolean | undefined> => {
return Reflect.getMetadata(keys.REQUIRED_FIELD, target) ?? new Map();
};

/**
* Fetch the ignored fields from entity metadata
* @hidden
* @param target
*/
export const getLoaderIgnoredFields = (
target: any
): Map<string, boolean | undefined> => {
return Reflect.getMetadata(keys.IGNORE_FIELD, target) ?? new Map();
};
1 change: 1 addition & 0 deletions src/GraphQLQueryBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export class GraphQLQueryBuilder<T extends typeof BaseEntity> {
* }
* ```
* @param fields
* @deprecated Use new `ConfigureLoader` decorator to require fields
*/
public selectFields(fields: string | Array<string>): GraphQLQueryBuilder<T> {
this._selectFields.push(fields);
Expand Down
134 changes: 89 additions & 45 deletions src/GraphQLQueryResolver.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Hash, LoaderOptions, Selection } from "./types";
import { LoaderNamingStrategyEnum } from "./enums/LoaderNamingStrategy";
import { Connection, SelectQueryBuilder } from "typeorm";
import { Connection, EntityMetadata, SelectQueryBuilder } from "typeorm";
import { Formatter } from "./lib/Formatter";
import { ColumnMetadata } from "typeorm/metadata/ColumnMetadata";
import { RelationMetadata } from "typeorm/metadata/RelationMetadata";
import { EmbeddedMetadata } from "typeorm/metadata/EmbeddedMetadata";
import {
getLoaderIgnoredFields,
getLoaderRequiredFields
} from "./ConfigureLoader";
import * as crypto from "crypto";

/**
Expand All @@ -18,6 +22,7 @@ export class GraphQLQueryResolver {
private readonly _namingStrategy: LoaderNamingStrategyEnum;
private _formatter: Formatter;
private readonly _maxDepth: number;

constructor({
primaryKeyColumn,
namingStrategy,
Expand All @@ -29,6 +34,23 @@ export class GraphQLQueryResolver {
this._maxDepth = maxQueryDepth ?? Infinity;
}

private static _generateChildHash(
alias: string,
propertyName: string,
length = 0
): string {
const hash = crypto.createHash("md5");
hash.update(`${alias}__${propertyName}`);

const output = hash.digest("hex");

if (length != 0) {
return output.slice(0, length);
}

return output;
}

/**
* Given a model and queryBuilder, will add the selected fields and
* relations required by a graphql field selection
Expand All @@ -49,12 +71,21 @@ export class GraphQLQueryResolver {
): SelectQueryBuilder<{}> {
const meta = connection.getMetadata(model);
if (selection && selection.children) {
const requiredFields = getLoaderRequiredFields(meta.target);
const ignoredFields = getLoaderIgnoredFields(meta.target);
const fields = meta.columns.filter(
field => field.isPrimary || field.propertyName in selection.children!
field =>
!ignoredFields.get(field.propertyName) &&
(field.isPrimary ||
field.propertyName in selection.children! ||
requiredFields.get(field.propertyName))
);

const embeddedFields = meta.embeddeds.filter(
embed => embed.propertyName in selection.children!
embed =>
!ignoredFields.get(embed.propertyName) &&
(embed.propertyName in selection.children! ||
requiredFields.get(embed.propertyName))
);

queryBuilder = this._selectFields(queryBuilder, fields, alias);
Expand All @@ -63,6 +94,7 @@ export class GraphQLQueryResolver {
queryBuilder,
embeddedFields,
selection.children,
meta,
alias
);

Expand All @@ -72,6 +104,7 @@ export class GraphQLQueryResolver {
selection.children,
meta.relations,
alias,
meta,
connection,
depth
);
Expand All @@ -87,22 +120,33 @@ export class GraphQLQueryResolver {
* @param queryBuilder
* @param embeddedFields
* @param children
* @param meta
* @param alias
* @private
*/
private _selectEmbeddedFields(
queryBuilder: SelectQueryBuilder<{}>,
embeddedFields: Array<EmbeddedMetadata>,
children: Hash<Selection>,
meta: EntityMetadata,
alias: string
) {
const embeddedFieldsToSelect: Array<Array<string>> = [];
const requiredFields = getLoaderRequiredFields(meta.target);
embeddedFields.forEach(field => {
// This is the name of the embedded entity on the TypeORM model
const embeddedFieldName = field.propertyName;

// Check if this particular field was queried for in GraphQL
if (children.hasOwnProperty(embeddedFieldName)) {
// If the embed was required, just select everything
if (requiredFields.get(embeddedFieldName)) {
embeddedFieldsToSelect.push(
field.columns.map(
({ propertyName }) => `${embeddedFieldName}.${propertyName}`
)
);

// Otherwise check if this particular field was queried for in GraphQL
} else if (children.hasOwnProperty(embeddedFieldName)) {
const embeddedSelection = children[embeddedFieldName];
// Extract the column names from the embedded field
// so we can compare it to what was requested in the GraphQL query
Expand Down Expand Up @@ -212,6 +256,7 @@ export class GraphQLQueryResolver {
* @param children
* @param relations
* @param alias
* @param meta
* @param connection
* @param depth
* @private
Expand All @@ -221,50 +266,49 @@ export class GraphQLQueryResolver {
children: Hash<Selection>,
relations: Array<RelationMetadata>,
alias: string,
meta: EntityMetadata,
connection: Connection,
depth: number
): SelectQueryBuilder<{}> {
relations.forEach(relation => {
// Join each relation that was queried
if (relation.propertyName in children) {
const childAlias = this._generateChildHash(
alias,
relation.propertyName,
10
);
queryBuilder = queryBuilder.leftJoin(
this._formatter.columnSelection(alias, relation.propertyName),
childAlias
);
// Recursively call createQuery to select and join any subfields
// from this relation
queryBuilder = this.createQuery(
relation.inverseEntityMetadata.target,
children[relation.propertyName],
connection,
queryBuilder,
childAlias,
depth + 1
);
}
});
return queryBuilder;
}

private _generateChildHash(
alias: string,
propertyName: string,
length = 0
): string {
const hash = crypto.createHash("md5");
hash.update(`${alias}__${propertyName}`);
const requiredFields = getLoaderRequiredFields(meta.target);
const ignoredFields = getLoaderIgnoredFields(meta.target);

const output = hash.digest("hex");
relations
.filter(relation => !ignoredFields.get(relation.propertyName))
.forEach(relation => {
const isRequired: boolean = !!requiredFields.get(relation.propertyName);
// Join each relation that was queried
if (relation.propertyName in children || isRequired) {
const childAlias = GraphQLQueryResolver._generateChildHash(
alias,
relation.propertyName,
10
);

if (length != 0) {
return output.slice(0, length);
}

return output;
// For now, if a relation is required, we load the full entity
// via leftJoinAndSelect. It does not recurse through the required
// relation.
queryBuilder = isRequired
? queryBuilder.leftJoinAndSelect(
this._formatter.columnSelection(alias, relation.propertyName),
childAlias
)
: queryBuilder.leftJoin(
this._formatter.columnSelection(alias, relation.propertyName),
childAlias
);
// Recursively call createQuery to select and join any subfields
// from this relation
queryBuilder = this.createQuery(
relation.inverseEntityMetadata.target,
children[relation.propertyName],
connection,
queryBuilder,
childAlias,
depth + 1
);
}
});
return queryBuilder;
}
}
2 changes: 1 addition & 1 deletion src/__tests__/builderOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe("Query Builder options", () => {

const vars = { offset: 0, limit: 10 };
const result = await graphql(schema, query, {}, { loader }, vars);
const reviews = await helpers.connection
const reviews = await connection
.getRepository(Review)
.createQueryBuilder("review")
.orderBy({ rating: "DESC" })
Expand Down
Loading

0 comments on commit 22e6a16

Please sign in to comment.