Skip to content

Commit

Permalink
feat(graphql-dynamodb-transformer): expose createdAt and updatedAt on…
Browse files Browse the repository at this point in the history
… model (#4149)

Expose createdAt and updatedAt automatically in the transformed schema. Added capability to change
the createdAt and updatedAt field names using timestamps configuration

fix #401
  • Loading branch information
yuth authored May 14, 2020
1 parent da712b8 commit 8e0662e
Show file tree
Hide file tree
Showing 20 changed files with 1,651 additions and 102 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ exports[`Test "create", "update", "delete" auth operations 3`] = `
## [End] Check authMode and execute owner/group checks **
## [Start] Prepare DynamoDB PutItem Request. **
$util.qr($context.args.input.put(\\"createdAt\\", $util.defaultIfNull($ctx.args.input.createdAt, $util.time.nowISO8601())))
$util.qr($context.args.input.put(\\"updatedAt\\", $util.defaultIfNull($ctx.args.input.updatedAt, $util.time.nowISO8601())))
#set( $createdAt = $util.time.nowISO8601() )
## Automatically set the createdAt timestamp. **
$util.qr($context.args.input.put(\\"createdAt\\", $util.defaultIfNull($ctx.args.input.createdAt, $createdAt)))
## Automatically set the updatedAt timestamp. **
$util.qr($context.args.input.put(\\"updatedAt\\", $util.defaultIfNull($ctx.args.input.updatedAt, $createdAt)))
$util.qr($context.args.input.put(\\"__typename\\", \\"Post\\"))
#set( $condition = {
\\"expression\\": \\"attribute_not_exists(#id)\\",
Expand Down Expand Up @@ -669,8 +672,11 @@ exports[`Test that operation overwrites queries in auth operations 3`] = `
## [End] Check authMode and execute owner/group checks **
## [Start] Prepare DynamoDB PutItem Request. **
$util.qr($context.args.input.put(\\"createdAt\\", $util.defaultIfNull($ctx.args.input.createdAt, $util.time.nowISO8601())))
$util.qr($context.args.input.put(\\"updatedAt\\", $util.defaultIfNull($ctx.args.input.updatedAt, $util.time.nowISO8601())))
#set( $createdAt = $util.time.nowISO8601() )
## Automatically set the createdAt timestamp. **
$util.qr($context.args.input.put(\\"createdAt\\", $util.defaultIfNull($ctx.args.input.createdAt, $createdAt)))
## Automatically set the updatedAt timestamp. **
$util.qr($context.args.input.put(\\"updatedAt\\", $util.defaultIfNull($ctx.args.input.updatedAt, $createdAt)))
$util.qr($context.args.input.put(\\"__typename\\", \\"Post\\"))
#set( $condition = {
\\"expression\\": \\"attribute_not_exists(#id)\\",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ exports[`test auth logic is enabled for iam/apiKey auth rules in response es res
"type Post @aws_api_key @aws_iam {
id: ID!
content: String
createdAt: AWSDateTime!
updatedAt: AWSDateTime!
secret: String @aws_iam
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ exports[`Connection on models with no codegen includes AttributeTypeEnum 1`] = `
id: ID!
title: String!
comments(filter: ModelCommentFilterInput, sortDirection: ModelSortDirection, limit: Int, nextToken: String): ModelCommentConnection
createdAt: AWSDateTime!
updatedAt: AWSDateTime!
}
type Comment {
id: ID!
content: String
createdAt: AWSDateTime!
updatedAt: AWSDateTime!
}
type ModelCommentConnection {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ exports[`Many-to-many with conflict resolution generates correct schema 1`] = `
_version: Int!
_deleted: Boolean
_lastChangedAt: AWSTimestamp!
createdAt: AWSDateTime!
updatedAt: AWSDateTime!
}
type PostEditor {
Expand All @@ -19,6 +21,8 @@ type PostEditor {
_version: Int!
_deleted: Boolean
_lastChangedAt: AWSTimestamp!
createdAt: AWSDateTime!
updatedAt: AWSDateTime!
}
type User {
Expand All @@ -28,6 +32,8 @@ type User {
_version: Int!
_deleted: Boolean
_lastChangedAt: AWSTimestamp!
createdAt: AWSDateTime!
updatedAt: AWSDateTime!
}
enum ModelSortDirection {
Expand Down Expand Up @@ -290,6 +296,8 @@ exports[`Many-to-many without conflict resolution generates correct schema 1`] =
id: ID!
title: String!
editors(editorID: ModelIDKeyConditionInput, filter: ModelPostEditorFilterInput, sortDirection: ModelSortDirection, limit: Int, nextToken: String): ModelPostEditorConnection
createdAt: AWSDateTime!
updatedAt: AWSDateTime!
}
type PostEditor {
Expand All @@ -298,12 +306,16 @@ type PostEditor {
editorID: ID!
post: Post!
editor: User!
createdAt: AWSDateTime!
updatedAt: AWSDateTime!
}
type User {
id: ID!
username: String!
posts(postID: ModelIDKeyConditionInput, filter: ModelPostEditorFilterInput, sortDirection: ModelSortDirection, limit: Int, nextToken: String): ModelPostEditorConnection
createdAt: AWSDateTime!
updatedAt: AWSDateTime!
}
enum ModelSortDirection {
Expand Down
123 changes: 94 additions & 29 deletions packages/graphql-dynamodb-transformer/src/DynamoDBModelTransformer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DeletionPolicy } from 'cloudform-types';
import { DirectiveNode, ObjectTypeDefinitionNode, InputObjectTypeDefinitionNode } from 'graphql';
import { DirectiveNode, ObjectTypeDefinitionNode, InputObjectTypeDefinitionNode, FieldDefinitionNode } from 'graphql';
import {
blankObject,
makeConnectionField,
Expand All @@ -10,6 +10,7 @@ import {
makeNonNullType,
ModelResourceIDs,
ResolverResourceIDs,
getBaseType,
} from 'graphql-transformer-common';
import { getDirectiveArguments, gql, Transformer, TransformerContext, SyncConfig } from 'graphql-transformer-core';
import {
Expand All @@ -27,7 +28,7 @@ import {
makeModelXConditionInputObject,
makeAttributeTypeEnum,
} from './definitions';
import { ModelDirectiveArgs } from './ModelDirectiveArgs';
import { ModelDirectiveArgs, getCreatedAtFieldName, getUpdatedAtFieldName } from './ModelDirectiveArgs';
import { ResourceFactory } from './resources';

export interface DynamoDBModelTransformerOptions {
Expand Down Expand Up @@ -58,37 +59,45 @@ export const CONDITIONS_MINIMUM_VERSION = 5;
* }
*/

export const directiveDefinition = gql`
directive @model(
queries: ModelQueryMap
mutations: ModelMutationMap
subscriptions: ModelSubscriptionMap
timestamps: TimestampConfiguration
) on OBJECT
input ModelMutationMap {
create: String
update: String
delete: String
}
input ModelQueryMap {
get: String
list: String
}
input ModelSubscriptionMap {
onCreate: [String]
onUpdate: [String]
onDelete: [String]
level: ModelSubscriptionLevel
}
enum ModelSubscriptionLevel {
off
public
on
}
input TimestampConfiguration {
createdAt: String
updatedAt: String
}
`;

export class DynamoDBModelTransformer extends Transformer {
resources: ResourceFactory;
opts: DynamoDBModelTransformerOptions;

constructor(opts: DynamoDBModelTransformerOptions = {}) {
super(
'DynamoDBModelTransformer',
gql`
directive @model(queries: ModelQueryMap, mutations: ModelMutationMap, subscriptions: ModelSubscriptionMap) on OBJECT
input ModelMutationMap {
create: String
update: String
delete: String
}
input ModelQueryMap {
get: String
list: String
}
input ModelSubscriptionMap {
onCreate: [String]
onUpdate: [String]
onDelete: [String]
level: ModelSubscriptionLevel
}
enum ModelSubscriptionLevel {
off
public
on
}
`,
);
super('DynamoDBModelTransformer', directiveDefinition);
this.opts = this.getOpts(opts);
this.resources = new ResourceFactory();
}
Expand Down Expand Up @@ -181,8 +190,42 @@ export class DynamoDBModelTransformer extends Transformer {

ctx.updateObject(newObj);
}
this.addTimestampFields(def, directive, ctx);
};

private addTimestampFields(def: ObjectTypeDefinitionNode, directive: DirectiveNode, ctx: TransformerContext): void {
const createdAtField = getCreatedAtFieldName(directive);
const updatedAtField = getUpdatedAtFieldName(directive);
const existingCreatedAtField = def.fields.find(f => f.name.value === createdAtField);
const existingUpdatedAtField = def.fields.find(f => f.name.value === updatedAtField);
// Todo: Consolidate how warnings are shown. Instead of printing them here, the invoker of transformer should get
// all the warnings together and decide how to render those warning
if (!DynamoDBModelTransformer.isTimestampCompatibleField(existingCreatedAtField)) {
console.log(
`${def.name.value}.${existingCreatedAtField.name.value} is of type ${getBaseType(
existingCreatedAtField.type,
)}. To support auto population change the type to AWSDateTime or String`,
);
}
if (!DynamoDBModelTransformer.isTimestampCompatibleField(existingUpdatedAtField)) {
console.log(
`${def.name.value}.${existingUpdatedAtField.name.value} is of type ${getBaseType(
existingUpdatedAtField.type,
)}. To support auto population change the type to AWSDateTime or String`,
);
}
const obj = ctx.getObject(def.name.value);
const newObj: ObjectTypeDefinitionNode = {
...obj,
fields: [
...obj.fields,
...(createdAtField && !existingCreatedAtField ? [makeField(createdAtField, [], wrapNonNull(makeNamedType('AWSDateTime')))] : []), // createdAt field
...(updatedAtField && !existingUpdatedAtField ? [makeField(updatedAtField, [], wrapNonNull(makeNamedType('AWSDateTime')))] : []), // updated field
],
};
ctx.updateObject(newObj);
}

private createMutations = (
def: ObjectTypeDefinitionNode,
directive: DirectiveNode,
Expand All @@ -205,6 +248,19 @@ export class DynamoDBModelTransformer extends Transformer {
let updateFieldNameOverride = undefined;
let deleteFieldNameOverride = undefined;

// timestamp fields
const createdAtField = getCreatedAtFieldName(directive);
const updatedAtField = getUpdatedAtFieldName(directive);

const existingCreatedAtField = def.fields.find(f => f.name.value === createdAtField);
const existingUpdatedAtField = def.fields.find(f => f.name.value === updatedAtField);

// auto populate the timestamp field only if they are of AWSDateTime type
const timestampFields = {
createdAtField: DynamoDBModelTransformer.isTimestampCompatibleField(existingCreatedAtField) ? createdAtField : undefined,
updatedAtField: DynamoDBModelTransformer.isTimestampCompatibleField(existingUpdatedAtField) ? updatedAtField : undefined,
};

// Figure out which mutations to make and if they have name overrides
if (directiveArguments.mutations === null) {
shouldMakeCreate = false;
Expand Down Expand Up @@ -232,14 +288,15 @@ export class DynamoDBModelTransformer extends Transformer {

// Create the mutations.
if (shouldMakeCreate) {
const createInput = makeCreateInputObject(def, nonModelArray, ctx, isSyncEnabled);
const createInput = makeCreateInputObject(def, directive, nonModelArray, ctx, isSyncEnabled);
if (!ctx.getType(createInput.name.value)) {
ctx.addInput(createInput);
}
const createResolver = this.resources.makeCreateResolver({
type: def.name.value,
nameOverride: createFieldNameOverride,
syncConfig: this.opts.SyncConfig,
timestamps: timestampFields,
});
const resourceId = ResolverResourceIDs.DynamoDBCreateResolverResourceID(typeName);
ctx.setResource(resourceId, createResolver);
Expand All @@ -260,6 +317,7 @@ export class DynamoDBModelTransformer extends Transformer {
type: def.name.value,
nameOverride: updateFieldNameOverride,
syncConfig: this.opts.SyncConfig,
timestamps: timestampFields,
});
const resourceId = ResolverResourceIDs.DynamoDBUpdateResolverResourceID(typeName);
ctx.setResource(resourceId, updateResolver);
Expand Down Expand Up @@ -627,4 +685,11 @@ export class DynamoDBModelTransformer extends Transformer {
private supportsConditions(context: TransformerContext) {
return context.getTransformerVersion() >= CONDITIONS_MINIMUM_VERSION;
}

private static isTimestampCompatibleField(field?: FieldDefinitionNode): boolean {
if (field && !(getBaseType(field.type) === 'AWSDateTime' || getBaseType(field.type) === 'String')) {
return false;
}
return true;
}
}
33 changes: 33 additions & 0 deletions packages/graphql-dynamodb-transformer/src/ModelDirectiveArgs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { getDirectiveArguments } from 'graphql-transformer-core';
import { DirectiveNode } from 'graphql';

export interface QueryNameMap {
get?: string;
list?: string;
Expand All @@ -19,8 +22,38 @@ export interface SubscriptionNameMap {
level?: ModelSubscriptionLevel;
}

export interface ModelDirectiveTimestampConfiguration {
createdAt?: string;
updatedAt?: string;
}

export interface ModelDirectiveArgs {
queries?: QueryNameMap;
mutations?: MutationNameMap;
subscriptions?: SubscriptionNameMap;
timestamps?: ModelDirectiveTimestampConfiguration;
}

export function getCreatedAtFieldName(directive: DirectiveNode): string | undefined {
return getTimestampFieldName(directive, 'createdAt', 'createdAt');
}

export function getUpdatedAtFieldName(directive: DirectiveNode): string | undefined {
return getTimestampFieldName(directive, 'updatedAt', 'updatedAt');
}

export function getTimestampFieldName(directive: DirectiveNode, fieldName: string, defaultFiledValue: string): string | undefined {
const directiveArguments: ModelDirectiveArgs = getDirectiveArguments(directive);
const timestamp = directiveArguments.timestamps;

/* When explicitly set to null, note that the check here is strict equality to null and not undefined
* type Post @model(timestamps:null) {
id: ID!
}
*/
if (timestamp === null) return null;
if (timestamp && timestamp[fieldName] !== undefined) {
return timestamp[fieldName];
}
return defaultFiledValue;
}
Loading

0 comments on commit 8e0662e

Please sign in to comment.