awslabs / dynamodb-data-mapper-js

A schema-based data mapper for Amazon DynamoDB.
https://awslabs.github.io/dynamodb-data-mapper-js/
Apache License 2.0
817 stars 106 forks source link

Type mismatch date / string #143

Open elSuperRiton opened 5 years ago

elSuperRiton commented 5 years ago

Hello,

When creating a table with type "S" for a date ( as required per documentation ) and using the type Date with the data mapper annotation I get a mismatch type error. It appears that the Date type does not parses into ISO-8601 string before saving to ddb.

akleiber commented 5 years ago

it is converted to a timestamp by the marshaller component - see https://github.com/awslabs/dynamodb-data-mapper-js/blob/master/packages/dynamodb-data-marshaller/src/marshallItem.ts#L118

krazibit commented 4 years ago

Is there any reason the milliseconds are being truncated?

kpenergy commented 3 years ago

We can treat Typescript's Date type as DynamoDB String by specifying this in the @attribute declaration:

@attribute({ type: "String"})
MyDate: Date

The obvious, universal way to store a date as a string is using the ISO-8601 format, which I'd expect the above to produce. However, it produces a more descriptive format, e.g. Mon Jul 26 2021 11:42:08 GMT+0100 (British Summer Time) (not sure what this is called).

Is there any way we can configure the DataMapper client to treat Date types as ISO-8601 strings when reading from/writing to a table?


It's worth mentioning, Date's are saved as ISO-8601 strings in some cases. For example, if we convert to JSON then convert back to an instance of our class, then save this instance to our table, the date is an ISO-8601 string.

@table("MyTable")
export class MyClass {
    @hashKey()
    Id!: number;

    @attribute({type: "String"})
    MyDate!: Date;
}

const tempObj = new MyClass();
Object.assign(tempObj, {
    Id: 1, 
    MyDate: new Date()
});

mapper.put(tempObj );

This produces the following entry in the table:

{
  "Id: {
    "N": "1", 
    "S": "Mon Jul 26 2021 11:42:08 GMT+0100 (British Summer Time)"
  }
}

However if we convert to JSON and back again, we get a different result.

const serialized = JSON.stringify(tempObj);
const deserialized = JSON.parse(serialized);

const myClass = new MyClass();
Object.assign(myClass, deserialized);

mapper.put(myClass);

This produces the following entry in the table

{
  "Id: {
    "N": "1", 
    "S": "2021-07-27T10:42:08.786Z"
  }
}

So this proves that the DataMapper client does support writing Date's as ISO-8601 strings. This inconsistency also creates a bit of a problem. Regardless of whether the source data came from JSON or from Typescript, we should be able to always have it saved to DynamoDB in the same format (IMO).

After all of this, I have one question:

How to configure the DataMapper client to treat Typescript's DateTime type as a DynamoDB String type formatted as an ISO-8601 string?

kpenergy commented 3 years ago

After playing around with this, I was able to store Date's as ISO-8601 string's as by using the Custom type and specifying custom logic on how to marshall/unmarshall the data:

@table("MyTable")
export class MyClass {
    @hashKey()
    Id!: number;

    @attribute({ type: "Custom",  marshall: (input) =>  {
        return { S: new Date(input).toISOString() }
    }, unmarshall: (input) => {
        if (input.S) return new Date(input.S?.toString())
    }})
    MyDate!: Date;
}

It's a bit messy and it has to be done for every date you want to store as an ISO string. Would be nice if there was some global setting to default to ISO string's for Dates.

eegli commented 3 years ago

@kpenergy you can take advantage of the CustomType interface for creating a reusable marshaller.

import { CustomType } from '@aws/dynamodb-data-marshaller';
import {
  attribute,
  hashKey,
  rangeKey,
  table,
} from '@aws/dynamodb-data-mapper-annotations';
...

// Create uniform ISO date strings for each date.
// Note that if you have a TTL attribute, this needs to be stored as a number 
const ISOdateType: CustomType<Date> = {
  type: 'Custom',
  marshall: (input: Date): AttributeValue => ({ S: input.toISOString() }),
  unmarshall: (persistedValue: AttributeValue): Date =>
    new Date(persistedValue.S!),
};

@table(config.AWS_TABLE_NAME)
export class History {
  @hashKey({ defaultProvider: () => 'history' })
  type?: string;

  // Use custom marshaller
  @rangeKey(ISOdateType)
  timestamp?: Date;

  // Provide a default as well
  @attribute({
    ...ISOdateType,
    defaultProvider: () => new Date(),
  })
  created_at?: Date;

  @attribute()
  count?: number;

  //... other attributes
}