Node.js ORM written in TypeScript for type lovers.

View the Project on GitHub twinlogix/typetta

Entities

A data model is made up of a set of entities. Such entities in Typetta are defined in GraphQL language, following the principles and syntax included in the official specification on graphql.org.

Entity definition

The basic definition of an entity is therefore that of a GraphQL type with a list of fields:

type User {
  id: ID!
  firstName: String
  lastName: String
}

Note that, according to GraphQL syntax, each field can be annotated as required or optional by adding or not a ! operator after the type name.

Without further specification, an entity such as the previous one will only produce the related TypeScript type. No data access components will be generated as it does not appear to be an entity directly stored on a data source.

Stored Entity

For an entity to be connected to its data structure on a data source and to allow the related CRUD operations, it must be explicitly annotated with GraphQL directives. There are currently two database drivers available in Typetta, one for SQL databases and one for MongoDB, and two related directives @sql and @mongodb. Each stored entity must also be annotated with @entity.

Once one of these annotations has been added, the entity is coupled to its data structure: to the table on SQL or to the collection on MongoDB.

MongoDB Entity

To specify to the system that an entity is linked to a MongoDB collection, it must be defined as follows:

type User @entity @mongodb {
  id: ID!
  firstName: String
  lastName: String
}

@mongodb directive also accepts two optional params:

Here is a complete example:

type User @entity @mongodb(source: "secondary-database", collection: "_users") {
  id: ID!
  firstName: String
  lastName: String
}

SQL Entity

To specify to the system that an entity is linked to a SQL table, it must be defined as follows:

type User @entity @sql {
  id: ID!
  firstName: String
  lastName: String
}

@sql directive also accepts two optional params:

Here is a complete example:

type User @entity @sql(source: "secondary-database", table: "_users") {
  id: ID!
  firstName: String
  lastName: String
}

ID

Each stored entity needs a unique identifier. Any entity field can be annotated as @id as long as it is of a scalar type and not another entity type. There is no correlation between the scalar GraphQL ID and the entity identifier.

To define the entity identifier you need to add the @id directive as in the following example:

type User @entity @mongodb {
  id: ID! @id
  firstName: String
  lastName: String
}

This directive also receives an optional from parameter, which can take the following values:

type User @entity @mongodb {
  id: String! @id(from: "db") @alias(value: "_id")
  name: String!
}

A simple example of generator policy can be the following where all entity identifiers are of the ID type and must be managed as UUIDs auto-generated by the system. To achieve this, the EntityManager can then be configured as follows:

import { v4 as uuidv4 } from 'uuid'

const entityManager = new EntityManager({
  scalars: {
    ID: {
      generator: () => uuidv4()
    }
  }
});

If you want different behaviour for a single DAO, you can create an override like the following:

import { v4 as uuidv4 } from 'uuid'

const entityManager = new EntityManager({
  scalars: {
    ID: {
      generator: () => uuidv4()
    }
  }
  overrides: {
    user: {
      scalars: {
        ID: {
          generator: () => 'user_' + uuidv4()
        }
      }
    }
  }
});

Enumerations

The GraphQL specification allows you to define enumerations with the following syntax:

enum UserType {
  ADMINISTRATOR
  CUSTOMER
}

An enumeration can be used just like a scalar to define the fields of an entity, as in the following example:

type User {
  id: ID!
  firstName: String
  lastName: String
  type: UserType!
}

Typetta supports enumerations both at TypeScript and database level. They are serialised to string data type by default on both SQL and MongoDB.

Embedded Entities

An embedded entity is an entity not directly stored in a dedicated SQL table or MongoDB collection, but only embedded within another entity. Embedded entities are a typical concept of document databases, which, however, can also be partially supported by SQL databases, as described below.

In Typetta, each entity can have one or more fields which are not of scalar type with reference to embedded entities. These referred entities cannot be annotated as @sql or @mongodb. Here is a simple example:

type Address {
  street: String
  city: String
  district: String
  zipcode: String
  country: String
}

type User @entity @mongodb {
  id: ID! @id
  firstName: String
  lastName: String
  address: Address
}

Typetta offers the most advanced support for embedded entities on MongoDB, which are translated into embedded documents, thus giving the possibility to select, filter and sort their fields. On SQL databases, these entities are instead flattened on multiple columns of the root table, also offering the possibility to select, filter and sort in this instance. However, embedded entity arrays are not supported on SQL due to database limits.

Default

Typetta offers the possibility to define default values for scalar fields of your entities using the @default directive:

type User @entity @mongodb {
  id: ID! @id
  firstName: String
  lastName: String
  creationDate: Date @default(from: "generator")
  live: Boolean @default(from: "middleware")
}

There are two options you can choose from to define the default value:

const entityManager = new EntityManager({
  scalars: {
    Date: {
      generator: () => new Date()
    }
  }
});
const entityManager = new EntityManager({
  overrides: {
    user: {
      middlewares: [
        defaultValueMiddleware('live', () => true),
      ]
    }
  }
});

By using a middleware you can create a default value that depends on EntityManager metadata, where you can store, for example, authorisation information about the caller.

Alias

Each field of a stored entity has a direct correspondence with the linked SQL column or the linked key of the MongoDB document and this correspondence is given by the name of the field itself. If you want to decouple the name of the entity of the data model from the database structure, you can use the @alias directive as follows:

type User @entity @mongodb {
  id: ID! @id
  firstName: String @alias(value: "name")
  lastName: String @alias(value: "surname")
  address: Address
}

In the above example, the generated TypeScript type will have the fields firstName: string and lastName: string, while the MongoDB documents will have two keys: name and surname.

Excluded Fields

You can define fields in the data model that have no correspondence in the SQL table or in the MongoDB collection. These fields are then reflected in the TypeScript data type but are neither serialised nor deserialised in the database.

To define an excluded field you can use the @exclude directive as follows:

type User @entity @mongodb {
  id: ID! @id
  firstName: String
  lastName: String
  excludedField: String @exclude
}

Schema

Typetta generates a schema object for each entity of the defined data model. That schema can be sometimes useful to build powerful middlewares that need a reflective view on the data model. Given the following entity definition:

type User @entity @mongodb {
  id: ID! @id
  firstName: String
  lastName: String
  dateOfBirth: Date!
}

Typetta automatically generates this schema:

{
  id: {
    scalar: 'ID', 
    required: true
  },
  firstName: {
    scalar: 'String', 
  },
  lastName: {
    scalar: 'String', 
  },
  dateOfBirth: {
    scalar: 'Date',
    required: true
  }
}

Schema Directives

Schema generation can be extended with any directive out of Typetta domains. Every unknown directives is added to the schema under directives field. This is particularly useful to implement middlewares with different behaviours based on domain model concepts.

Following an example of a custom metadata on a dateOfBirth field:

type User @entity @mongodb {
  id: ID! @id
  firstName: String
  lastName: String
  dateOfBirth: Date! @addDays(value: 1)
}

And the schema resulting from it:

{
  id: {
    scalar: 'ID', 
    required: true
  },
  firstName: {
    scalar: 'String', 
  },
  lastName: {
    scalar: 'String', 
  },
  dateOfBirth: {
    scalar: 'Date',
    required: true,
    directives: {
      addDays: { value: 1 }
    }
  }
}

Now you can easily implement a middleware that, reading that schema, increment all the fields containing the addDays metadata by n days before insertion.

Here’s another example of a field with a custom directive that has more than one key.

type SomeType @entity @mongodb {
  id: ID! @id(from: "db") @alias(value: "_id")
  someField: Date @metadata(value: true, values: [ "one", 2, "three", false ], obj: { f: 1.1 }) @addDays(value: 2)
}

And the schema resulting from it:

{
  id: {
    type: 'scalar',
    scalar: 'ID',
    isId: true,
    generationStrategy: 'db',
    required: true,
    alias: '_id',
  },
  someField: {
    type: 'scalar',
    scalar: 'Date',
    directives: {
      metadata: {
        value: true,
        values: ['one', 2, 'three', false],
        obj: { f: 1.1 }
      },
      addDays: { value: 2 }
    },
  },
}