@hgraph/storage
TypeScript icon, indicating that this package has built-in type declarations

1.1.8 • Public • Published

Hypergraph Storage

This is a package for accessing databases using TypeORM, that comes with the following benefits:

  • Built for TypeScript and typing support
  • Works best with GraphQL especially libraries like TypeGraphQL
  • Comes with easy to use Query builder with elegant and convenient syntax with typing support
  • Supports pagination through PaginatedQuery builder
  • Built on top of TypeORM, hence comes with all the benefits that it provides:
    • Supports MySQL / MariaDB / Postgres / CockroachDB / SQLite / Microsoft SQL Server / Oracle / SAP Hana / sql.js.
    • Works in NodeJS / Browser / Ionic / Cordova / React Native / NativeScript / Expo / Electron platforms.
    • Entities and columns.
    • Database-specific column types.
    • Entity manager.
    • Clean object relational model.
    • Associations (relations).
    • Eager and lazy relations.
    • Uni-directional, bi-directional and self-referenced relations.
    • Supports multiple inheritance patterns.
    • Cascades.
    • Indices.
    • Transactions.
    • Migrations and automatic migrations generation.
    • Connection pooling.
    • Replication.
    • Using multiple database instances.
    • Working with multiple databases types.
    • Cross-database and cross-schema queries.
    • Left and inner joins.
    • Proper pagination for queries using joins.
    • Query caching.
    • Streaming raw results.
    • Logging.
    • Listeners and subscribers (hooks).
    • Supports MongoDB NoSQL database.
    • TypeScript and JavaScript support.
    • ESM and CommonJS support.
    • Produced code is performant, flexible, clean and maintainable.

Install

Using npm:

npm install @hgraph/storage

Using yarn:

yarn add @hgraph/storage

Usage

Define entity class. See this for more examples.

import { Repository } from '@hgraph/storage'
import { Column, PrimaryColumn } from 'typeorm'

@Entity()
class User {
  @PrimaryColumn()
  id!: string

  @Column()
  name!: string

  @Column()
  username!: string

  @Column({ nullable: true })
  bio?: string

  @Column({ nullable: true })
  verified?: boolean

  @Column({ nullable: true })
  followers?: number
}

Define repository class

class UserRepository extends Repository<User> {
  constructor() {
    super(User)
  }
}

Initialize the data source

await initializeDataSource({
  type: 'postgres',
  host: 'localhost',
  port: 5432,
  database: 'test',
  username: 'postgres',
  password: '',
  entities: [User],
  synchronize: true,
})

Or you can use environment variables

DB_TYPE: postgres
DB_HOST: localhost
DB_PORT: "5432"
DB_NAME: test
DB_USER: postgres
DB_PASSWORD:
DB_SYNCHRONIZE: "true"

and then initialize

await initializeDataSource({
  entities: [User],
})

// or use

await initializeDataSource({
  entities: [`${__dirname}/**/*-entity.{ts,js}`],
})
await

Get instance of the user repository.

const userRepository = new UserRepository()

If you are using dependency injection library like tsyringe use the following. This works best for us when using it with GraphQL's per query cache.

import { container } from 'tsyringe'

const userRepository = container.resolve(UserRepository)

Fetch Records

The repository class comes with .find* methods that you can use to query data using Query builder:

find

You can fetch multiple records from a table using find method. This method supports pagination.

// find using a query, but paginate
const { next, items } = await userRepository.find(query =>
  query.whereEqualTo('name', 'John Doe').next(nextTokenFromBefore).limit(200),
)

will execute the following sql query and return first 200 records and a next token that you can use for next page. OFFSET will be calculated from nextTokenFromBefore

SELECT * FROM "user"
WHERE "name" = 'John Doe'
OFFSET <OFFSET>
LIMIT 200

findById

You can query a record by id directly using findById method.

const user = await userRepository.findById('user1')

will execute a query

SELECT * FROM "user"
WHERE "id" = 'user1'

findByIds

You can find more than one record by its ids by using findByIds.

// find many by ids
const users = await userRepository.findByIds(['user1', 'user2'])

will execute a query

SELECT * FROM "user"
WHERE "id" IN ('user1', 'user2')

findAll

You can use findAll to get all records without pagination. This method provides support for in memory filter and pagination callback.

// find all from the entity table
const users = await userRepository.findAll()

// find all using a query
const users = await userRepository.findAll(query => query.whereEqualTo('name', 'John Doe'))

// find all using a query, filter and a pagination callback
const users = await userRepository.findAll(
  query => query.whereEqualTo('name', 'John Doe'),
  item => someLogicToFilter(item),
  (items, next) => console.log('fetched a page', items, next),
)

findOne

This method works just like findAll but returns only the first record.

// find one from the top
const user = await userRepository.findOne()

// find one using a query
const user = await userRepository.findOne(query => query.whereEqualTo('name', 'John Doe'))

Query Builder

Query class provides an easy to use implementation for constructing complex SQL query. It allows you to build SQL queries using elegant and convenient syntax with typing support. Here is the entity setup for this example.

Query

import { Query } from '@hgraph/storage'

const repo = new UserRepository()
const query = new Query(repo)

  // select columns
  .select('bio')
  .select('id')
  .select('email')

  // where conditions
  .whereEqualTo('id', 'id1')
  .whereNotEqualTo('id', 'id1')

  // numeric checks
  .whereMoreThan('version', 1)
  .whereMoreThanOrEqual('version', 1)
  .whereLessThan('version', 1)
  .whereLessThanOrEqual('version', 1)
  .whereBetween('version', 1, 2)

  // numeric "NOT" operators
  .whereNotMoreThan('version', 1)
  .whereNotMoreThanOrEqual('version', 1)
  .whereNotLessThan('version', 0)
  .whereNotLessThanOrEqual('version', 1)

  // search
  .whereTextContains('bio', 'true')
  .whereTextStartsWith('bio', 'any')
  .whereTextEndsWith('bio', 'any')

  // case insensitive search
  .whereTextInAnyCaseContains('bio', 'any')
  .whereTextInAnyCaseStartsWith('bio', 'any')
  .whereTextInAnyCaseEndsWith('bio', 'any')

  // "IN" operator
  .whereIn('role', [UserRole.ADMIN, UserRole.USER])

  // null checks
  .whereIsNull('name')
  .whereIsNotNull('name')

  // array operations
  .whereArrayContains('tags', 'new')
  .whereArrayContainsAny('tags', ['new', 'trending'])

  // search on related tables
  .whereJoin('photos', q => q.whereIsNotNull('url'))

  // build "OR" condition
  .whereOr(
    query => query.whereEqualTo('id', '10'),
    query => query.whereEqualTo('id', '10'),
  )

  // sort
  .orderByAscending('version')
  .orderByDescending('createdAt')

  // fetch related entities
  .fetchRelation('photos', 'album')
  .loadRelationIds() // load only 'id', not required with `fetchRelation`

  // enable or set timeout for `cache`
  .cache(5000 ?? true)

PaginatedQuery

PaginatedQuery, in addition to the following, supports all the methods in Query.

import { PaginatedQuery } from '@hgraph/storage'

const repo = new UserRepository()
const query = new PaginatedQuery(repo)

  // use individual methods
  .next('token')
  .limit(10)

  // or use this method
  .pagination({ next: 'token', limit: 10 })

Insert & Update

You can insert and update records using the following methods:

save

This method will insert if the id (if provided) does not exist in the database, or will update the existing record.

const user = await userRepository.save({ id: 'user1', name: 'John Doe', username: 'johnd' })

saveMany

You can insert or update more than one record using in a step using saveMany. Just like save the record will inserted if id does not record.

const users = await userRepository.saveMany([
  { id: 'user1', name: 'John Doe', username: 'johndoe' },
  { id: 'user2', name: 'Mejia Henderso', username: 'mh' },
])

insert

You can insert a record using insert method. "id" will be auto populated, if omitted.

const user = await userRepository.insert({ name: 'John Done', username: 'johndoe' })

insertMany

You can insert multiple users at once using insertMany.

const users = await userRepository.insertMany([
  { name: 'John Doe', username: 'johndoe' },
  { name: 'Mejia Henderso', username: 'mh' },
])

update

Use this method to update a record, "id" is mandatory input.

const user = await userRepository.update({ id: 'user1', username: 'john' })

updateMany

You can update more than one record using a query using updateMany.

// update multiple records at once using a query
const users = await userRepository.updateMany(query => query.whereEqualTo('username', 'johndoe'), {
  verified: true,
})

Count

This method counts entities that match query (if provided) and returns a numeric value.

// count all users
const count = await userRepository.count()

// count all users with a query
const count = await userRepository.count(query => query.whereEqualTo('name', 'John Doe'))

Increment

You can increment or decrement the value of a numeric column using an id or a query using this method.

// increment by id
const user = await userRepository.increment('user1', 'followers', 1)

// decrement by id
const user = await userRepository.increment('user1', 'followers', -1)

// increment by query
const user = await userRepository.increment(
  query => query.whereEqualTo('name', 'John Doe'),
  'followers',
  1,
)

Delete & Restore

You can permanently delete a record from the table using delete. Alternatively you may choose to use soft delete by passing { softDelete: true } option. This will keep the record in the table, however will populate deletedAt column with the current time stamp. TypeORM will inject "deletedAt" IS NULL to all queries by default, thus eliminating any records that were soft deleted. restore will remove deletedAt value.

// delete a user
await userRepository.delete('user1') // delete by id
await userRepository.delete(query => query.whereEqualTo('verified', false)) // delete by query

// soft delete a user
await userRepository.delete('user1', { softDelete: true }) // soft delete by id
await userRepository.delete(query => query.whereEqualTo('verified', false), { softDelete: true }) // soft delete by query

// restore a user if soft deleted
await userRepository.restore('user1') // restore by id
await userRepository.restore(query => query.whereEqualTo('verified', false)) // restore by query

Using with Cloud Firestore

This library comes with support for firestore, however you will have to initialize the datasource and repositories as show below. Most of the APIs are compatible with each other.

import { initializeFirestore, FirestoreRepository } from '@hgraph/storage'

await initializeFirestore({
  serviceAccountConfig: 'string', // path to the file where service account config (JSON) is placed
})

// and create repositories from
export class UserRepository extends FirestoreRepository<UserEntity> {
  constructor() {
    super(UserEntity)
  }
}

Queries

The following apis are supported

import { FirestoreQuery } from '@hgraph/storage'

const repo = new UserRepository()
const query = new FirestoreQuery(repo)

  // select columns
  .select('bio')
  .select('id')
  .select('email')

  // where conditions
  .whereEqualTo('id', 'id1')
  .whereNotEqualTo('id', 'id1')

  // numeric checks
  .whereMoreThan('version', 1)
  .whereMoreThanOrEqual('version', 1)
  .whereLessThan('version', 1)
  .whereLessThanOrEqual('version', 1)
  .whereBetween('version', 1, 2)

  // numeric "NOT" operators
  .whereNotMoreThan('version', 1)
  .whereNotMoreThanOrEqual('version', 1)
  .whereNotLessThan('version', 0)
  .whereNotLessThanOrEqual('version', 1)

  // search
  // .whereTextContains('bio', 'true')                    // NOT SUPPORTED
  .whereTextStartsWith('bio', 'any')
  // .whereTextEndsWith('bio', 'any')                     // NOT SUPPORTED

  // case insensitive search
  // .whereTextInAnyCaseContains('bio', 'any')            // NOT SUPPORTED
  // .whereTextInAnyCaseStartsWith('bio', 'any')          // NOT SUPPORTED
  // .whereTextInAnyCaseEndsWith('bio', 'any')            // NOT SUPPORTED

  // "IN" operator
  .whereIn('role', [UserRole.ADMIN, UserRole.USER])

  // null checks
  .whereIsNull('name')
  .whereIsNotNull('name')

  // array operations
  .whereArrayContains('tags', 'new')
  .whereArrayContainsAny('tags', ['new', 'trending'])

  // search on related tables
  // .whereJoin('photos', q => q.whereIsNotNull('url'))   // NOT SUPPORTED YET

  // build "OR" condition
  .whereOr(
    query => query.whereEqualTo('id', '10'),
    query => query.whereEqualTo('id', '10'),
  )

  // sort
  .orderByAscending('version')
  .orderByDescending('createdAt')

  // fetch related entities
  // .fetchRelation('photos', 'album')                 // NOT SUPPORTED YET
  .loadRelationIds()

  // enable or set timeout for `cache`
  .cache(5000 ?? true) // NOT EFFECT

Modification API

// save a user
const user = await userRepository.save({ id: 'user1', name: 'John Doe', username: 'johnd' })

// save multiple users
const users = await userRepository.saveMany([
  { id: 'user1', name: 'John Doe', username: 'johndoe' },
  { id: 'user2', name: 'Mejia Henderso', username: 'mh' },
])

// update a user
const user = await userRepository.update({ id: 'user1', username: 'john' })

// update multiple records at once using a query
const users = await userRepository.updateMany(query => query.whereEqualTo('username', 'johndoe'), {
  verified: true,
})

// count all users
const count = await userRepository.count()

// count all users with a query
const count = await userRepository.count(query => query.whereEqualTo('name', 'John Doe'))

// increment by id
const user = await userRepository.increment('user1', 'followers', 1)

// decrement by id
const user = await userRepository.increment('user1', 'followers', -1)

// increment by query
const user = await userRepository.increment(
  query => query.whereEqualTo('name', 'John Doe'),
  'followers',
  1,
)

// safest way to add an entity to an array "following" is as below
const user = await userRepository.addToArray('following', {
  id: 'user2',
  name: 'Mejia Henderso',
  username: 'mh',
})

// safest way to remove an entity from an array "following" is as below
const user = await userRepository.removeFromArray('following', {
  id: 'user2',
  name: 'Mejia Henderso',
  username: 'mh',
})

// delete a user
await userRepository.delete('user1') // delete by id
await userRepository.delete(query => query.whereEqualTo('verified', false)) // delete by query

// soft delete a user - NOT SUPPORTED
// await userRepository.delete('user1', { softDelete: true }) // soft delete by id
// await userRepository.delete(query => query.whereEqualTo('verified', false), { softDelete: true }) // soft delete by query

// restore a user if soft deleted - NOT SUPPORTED YET
// await userRepository.restore('user1') // restore by id
// await userRepository.restore(query => query.whereEqualTo('verified', false)) // restore by query

Using Cache

Id cache is very important for the performance of queries especially when using it with GraphQL. Therefor Hypergraph uses officially recommended library dataloader to gain performance via batching and caching.

import { RepositoryWithIdCache } from '@hgraph/storage'

class UserRepository extends RepositoryWithIdCache<User> {
  constructor() {
    super(User)
  }
}

or if you are using firestore do the following

import { FirestoreRepositoryWithIdCache } from '@hgraph/storage'

class UserRepository extends FirestoreRepositoryWithIdCache<User> {
  constructor() {
    super(User)
  }
}

Alternatively you can build your own cache-by-a-property using the following code.

import { Repository, RepositoryOptions, WithCache } from '@hgraph/storage'
import { ObjectLiteral } from 'typeorm'
import { ClassType } from 'tsds-tools'

@WithCache('name')
class RepositoryWithNameCache<Entity extends ObjectLiteral> extends Repository<Entity> {
  constructor(
    public readonly entity: ClassType<Entity>,
    public readonly options?: RepositoryOptions,
  ) {
    super(entity, options)
  }
}

class UserRepository extends RepositoryWithNameCache<User> {
  constructor() {
    super(User)
  }
}

For firestore:

import {
  FirestoreRepository,
  FirestoreRepositoryOptions,
  WithFirestoreCache,
} from '@hgraph/storage'
import { ObjectLiteral } from 'typeorm'
import { ClassType } from 'tsds-tools'

@WithFirestoreCache('name')
class FirestoreRepositoryWithNameCache<
  Entity extends ObjectLiteral,
> extends FirestoreRepository<Entity> {
  constructor(
    public readonly entity: ClassType<Entity>,
    public readonly options?: FirestoreRepositoryOptions,
  ) {
    super(entity, options)
  }
}

class UserRepository extends FirestoreRepositoryWithNameCache<User> {
  constructor() {
    super(User)
  }
}

TypeORM DataSource

You can access TypeORM DataSource directly, to tap on to any TypeORM feature that is not covered by this library by using the following code:

import { initializeDataSource } from '@hgraph/storage'
import { container } from 'tsyringe'
import { DataSource } from 'typeorm'

async function run() {
  await initializeDataSource({
    type: 'postgres',
    ...
  })

  const dataSource = container.resolve(DataSource)
}

Testing

This package comes with an in-memory implementation of the database based on pg-mem to support testing. Use initializeMockDataSource to initialize in-memory database.

import { initializeMockDataSource } from '@hgraph/storage/dist/typeorm-mock'

describe('Test suite', () => {
  let dataSource: MockTypeORMDataSource

  class UserRepository extends Repository<UserEntity> {
    constructor() {
      super(UserEntity)
    }
  }

  class PhotoRepository extends Repository<PhotoEntity> {
    constructor() {
      super(PhotoEntity)
    }
  }

  beforeEach(async () => {
    dataSource = await initializeMockDataSource({
      type: 'postgres',
      database: 'test',
      entities: [UserEntity, PhotoEntity],
      synchronize: false,
      retry: 0,
    })
    await container.resolve(UserRepository).saveMany(data.users as any)
    await container.resolve(PhotoRepository).saveMany(data.photos)
  })

  afterEach(async () => {
    dataSource?.destroy()
  })

  test('should pass sanity test', async () => {
    const repository = container.resolve(PhotoRepository)
    const result = await repository.count()
    expect(result).toEqual(data.photos.length)
  })
})

Testing with firestore

import { initializeMockFirestore } from '@hgraph/storage/dist/firestore-repository/firestore-mock'

describe('Test suite', () => {
  let dataSource: MockTypeORMDataSource

  class UserRepository extends Repository<UserEntity> {
    constructor() {
      super(UserEntity)
    }
  }

  class PhotoRepository extends Repository<PhotoEntity> {
    constructor() {
      super(PhotoEntity)
    }
  }

  async function saveAll() {
    await Promise.all([
      container.resolve(UserRepository).saveMany(data.users as any),
      container.resolve(PhotoRepository).saveMany(data.photos),
    ])
  }

  async function deleteAll() {
    await Promise.all([
      container.resolve(UserRepository).delete(query => query),
      container.resolve(PhotoRepository).delete(query => query),
    ])
  }

  beforeAll(async () => {
    // OPTION 1: RUN WITH EMULATOR
    // const firestore = admin.initializeApp({ projectId: 'test-e9d5b' }).firestore()
    // firestore.settings({ host: 'localhost:8080', ssl: false })
    // container.registerInstance(FIRESTORE_INSTANCE, firestore)

    // OPTION 2: RUN WITH MOCK
    initializeMockFirestore()
  })

  beforeEach(async () => {
    await deleteAll()
    await saveAll()
  })

  afterAll(async () => {
    await deleteAll()
  })

  test('should pass sanity test', async () => {
    const repository = container.resolve(PhotoRepository)
    const result = await repository.count()
    expect(result).toEqual(data.photos.length)
  })
})

Readme

Keywords

none

Package Sidebar

Install

npm i @hgraph/storage

Weekly Downloads

20

Version

1.1.8

License

MIT

Unpacked Size

259 kB

Total Files

103

Last publish

Collaborators

  • rintoj