Skip to content

Commit

Permalink
Merge pull request #239 from dennisofficial/main
Browse files Browse the repository at this point in the history
Generic Types for stricter type safety, and search
  • Loading branch information
guyroyse authored Jul 15, 2024
2 parents 0c38871 + 94f6b8f commit 1acd1cf
Show file tree
Hide file tree
Showing 16 changed files with 471 additions and 397 deletions.
4 changes: 2 additions & 2 deletions lib/client/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createClient, createCluster, RediSearchSchema, SearchOptions } from 'redis'

import { Repository } from '../repository'
import { Schema } from '../schema'
import {InferSchema, Schema} from '../schema'
import { RedisOmError } from '../error'

/** A conventional Redis connection. */
Expand Down Expand Up @@ -116,7 +116,7 @@ export class Client {
* @param schema The schema.
* @returns A repository for the provided schema.
*/
fetchRepository(schema: Schema): Repository {
fetchRepository<T extends Schema<any>>(schema: T): Repository<InferSchema<T>> {
this.#validateRedisOpen()
return new Repository(schema, this)
}
Expand Down
10 changes: 6 additions & 4 deletions lib/entity/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,25 @@ export const EntityId = Symbol('entityId')
/** The Symbol used to access the keyname of an {@link Entity}. */
export const EntityKeyName = Symbol('entityKeyName')

/** Defines the objects returned from calls to {@link Repository | repositories }. */
export type Entity = EntityData & {

export type EntityInternal = {
/** The unique ID of the {@link Entity}. Access using the {@link EntityId} Symbol. */
[EntityId]?: string

/** The key the {@link Entity} is stored under inside of Redis. Access using the {@link EntityKeyName} Symbol. */
[EntityKeyName]?: string
}

/** Defines the objects returned from calls to {@link Repository | repositories }. */
export type Entity = EntityData & EntityInternal
export type EntityKeys<T extends Entity> = Exclude<keyof T, keyof EntityInternal>;

/** The free-form data associated with an {@link Entity}. */
export type EntityData = {
[key: string]: EntityDataValue | EntityData | Array<EntityDataValue | EntityData>
}

/** Valid types for values in an {@link Entity}. */
export type EntityDataValue = string | number | boolean | Date | Point | null | undefined | Array<EntityDataValue | EntityData>
export type EntityDataValue = string | number | boolean | Date | Point | null | undefined | Array<EntityDataValue | EntityData>

/** Defines a point on the globe using longitude and latitude. */
export type Point = {
Expand Down
68 changes: 33 additions & 35 deletions lib/repository/repository.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Client, CreateOptions, RedisConnection, RedisHashData, RedisJsonData } from '../client'
import { Entity, EntityId, EntityKeyName } from '../entity'
import { buildRediSearchSchema } from '../indexer'
import { Schema } from '../schema'
import { Search, RawSearch } from '../search'
import { fromRedisHash, fromRedisJson, toRedisHash, toRedisJson } from '../transformer'
import {Client, CreateOptions, RedisConnection, RedisHashData, RedisJsonData} from '../client'
import {Entity, EntityId, EntityKeyName} from '../entity'
import {buildRediSearchSchema} from '../indexer'
import {Schema} from '../schema'
import {RawSearch, Search} from '../search'
import {fromRedisHash, fromRedisJson, toRedisHash, toRedisJson} from '../transformer'

/**
* A repository is the main interaction point for reading, writing, and
Expand Down Expand Up @@ -41,19 +41,19 @@ import { fromRedisHash, fromRedisJson, toRedisHash, toRedisJson } from '../trans
* .and('aBoolean').is.false().returnAll()
* ```
*/
export class Repository {
export class Repository<T extends Entity = Record<string, any>> {

// NOTE: Not using "#" private as the spec needs to check calls on this class. Will be resolved when Client class is removed.
private client: Client
#schema: Schema
private readonly client: Client
readonly #schema: Schema<T>

/**
* Creates a new {@link Repository}.
*
* @param schema The schema defining that data in the repository.
* @param client A client to talk to Redis.
* @param clientOrConnection A client to talk to Redis.
*/
constructor(schema: Schema, clientOrConnection: Client | RedisConnection) {
constructor(schema: Schema<T>, clientOrConnection: Client | RedisConnection) {
this.#schema = schema
if (clientOrConnection instanceof Client) {
this.client = clientOrConnection
Expand Down Expand Up @@ -131,7 +131,7 @@ export class Repository {
* @param entity The Entity to save.
* @returns A copy of the provided Entity with EntityId and EntityKeyName properties added.
*/
async save(entity: Entity): Promise<Entity>
async save(entity: T): Promise<T>

/**
* Insert or update the {@link Entity} to Redis using the provided entityId.
Expand All @@ -140,10 +140,10 @@ export class Repository {
* @param entity The Entity to save.
* @returns A copy of the provided Entity with EntityId and EntityKeyName properties added.
*/
async save(id: string, entity: Entity): Promise<Entity>
async save(id: string, entity: T): Promise<T>

async save(entityOrId: Entity | string, maybeEntity?: Entity): Promise<Entity> {
let entity: Entity | undefined
async save(entityOrId: T | string, maybeEntity?: T): Promise<T> {
let entity: T | undefined
let entityId: string | undefined

if (typeof entityOrId !== 'string') {
Expand All @@ -155,7 +155,7 @@ export class Repository {
}

const keyName = `${this.#schema.schemaName}:${entityId}`
const clonedEntity = { ...entity, [EntityId]: entityId, [EntityKeyName]: keyName }
const clonedEntity = { ...entity, [EntityId]: entityId, [EntityKeyName]: keyName } as T
await this.writeEntity(clonedEntity)

return clonedEntity
Expand All @@ -168,7 +168,7 @@ export class Repository {
* @param id The ID of the {@link Entity} you seek.
* @returns The matching Entity.
*/
async fetch(id: string): Promise<Entity>
async fetch(id: string): Promise<T>

/**
* Read and return the {@link Entity | Entities} from Redis with the given IDs. If
Expand All @@ -177,7 +177,7 @@ export class Repository {
* @param ids The IDs of the {@link Entity | Entities} you seek.
* @returns The matching Entities.
*/
async fetch(...ids: string[]): Promise<Entity[]>
async fetch(...ids: string[]): Promise<T[]>

/**
* Read and return the {@link Entity | Entities} from Redis with the given IDs. If
Expand All @@ -186,9 +186,9 @@ export class Repository {
* @param ids The IDs of the {@link Entity | Entities} you seek.
* @returns The matching Entities.
*/
async fetch(ids: string[]): Promise<Entity[]>
async fetch(ids: string[]): Promise<T[]>

async fetch(ids: string | string[]): Promise<Entity | Entity[]> {
async fetch(ids: string | string[]): Promise<T | T[]> {
if (arguments.length > 1) return this.readEntities([...arguments])
if (Array.isArray(ids)) return this.readEntities(ids)

Expand Down Expand Up @@ -246,6 +246,7 @@ export class Repository {
* ids. If a particular {@link Entity} is not found, does nothing.
*
* @param ids The IDs of the {@link Entity | Entities} you wish to delete.
* @param ttlInSeconds The time to live in seconds.
*/
async expire(ids: string[], ttlInSeconds: number): Promise<void>

Expand Down Expand Up @@ -298,7 +299,7 @@ export class Repository {
*
* @returns A {@link Search} object.
*/
search(): Search {
search(): Search<T> {
return new Search(this.#schema, this.client)
}

Expand All @@ -313,20 +314,19 @@ export class Repository {
* @query The raw RediSearch query you want to rune.
* @returns A {@link RawSearch} object.
*/
searchRaw(query: string): RawSearch {
searchRaw(query: string): RawSearch<T> {
return new RawSearch(this.#schema, this.client, query)
}

private async writeEntity(entity: Entity): Promise<void> {
return this.#schema.dataStructure === 'HASH' ? this.writeEntityToHash(entity) : this.writeEntityToJson(entity)
private async writeEntity(entity: T): Promise<void> {
return this.#schema.dataStructure === 'HASH' ? this.#writeEntityToHash(entity) : this.writeEntityToJson(entity)
}

private async readEntities(ids: string[]): Promise<Entity[]> {
private async readEntities(ids: string[]): Promise<T[]> {
return this.#schema.dataStructure === 'HASH' ? this.readEntitiesFromHash(ids) : this.readEntitiesFromJson(ids)
}

// TODO: make this actually private... like with #
private async writeEntityToHash(entity: Entity): Promise<void> {
async #writeEntityToHash(entity: Entity): Promise<void> {
const keyName = entity[EntityKeyName]!
const hashData: RedisHashData = toRedisHash(this.#schema, entity)
if (Object.keys(hashData).length === 0) {
Expand All @@ -336,14 +336,13 @@ export class Repository {
}
}

private async readEntitiesFromHash(ids: string[]): Promise<Entity[]> {
private async readEntitiesFromHash(ids: string[]): Promise<T[]> {
return Promise.all(
ids.map(async (entityId) => {
ids.map(async (entityId): Promise<T> => {
const keyName = this.makeKey(entityId)
const hashData = await this.client.hgetall(keyName)
const entityData = fromRedisHash(this.#schema, hashData)
const entity = { ...entityData, [EntityId]: entityId, [EntityKeyName]: keyName }
return entity
return {...entityData, [EntityId]: entityId, [EntityKeyName]: keyName} as T
}))
}

Expand All @@ -353,14 +352,13 @@ export class Repository {
await this.client.jsonset(keyName, jsonData)
}

private async readEntitiesFromJson(ids: string[]): Promise<Entity[]> {
private async readEntitiesFromJson(ids: string[]): Promise<T[]> {
return Promise.all(
ids.map(async (entityId) => {
ids.map(async (entityId): Promise<T> => {
const keyName = this.makeKey(entityId)
const jsonData = await this.client.jsonget(keyName) ?? {}
const entityData = fromRedisJson(this.#schema, jsonData)
const entity = {...entityData, [EntityId]: entityId, [EntityKeyName]: keyName }
return entity
return {...entityData, [EntityId]: entityId, [EntityKeyName]: keyName} as T
}))
}

Expand Down
4 changes: 3 additions & 1 deletion lib/schema/definitions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {Entity, EntityKeys} from "$lib/entity";

/** Valid field types for a {@link FieldDefinition}. */
export type FieldType = 'boolean' | 'date' | 'number' | 'number[]' | 'point' | 'string' | 'string[]' | 'text'

Expand Down Expand Up @@ -120,4 +122,4 @@ export type FieldDefinition =
TextFieldDefinition

/** Group of {@link FieldDefinition}s that define the schema for an {@link Entity}. */
export type SchemaDefinition = Record<string, FieldDefinition>
export type SchemaDefinition<T extends Entity = Record<string, any>> = Record<EntityKeys<T>, FieldDefinition>
2 changes: 1 addition & 1 deletion lib/schema/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { AllFieldDefinition, FieldDefinition, FieldType } from './definitions'
*/
export class Field {

#name: string
readonly #name: string
#definition: AllFieldDefinition

/**
Expand Down
47 changes: 30 additions & 17 deletions lib/schema/schema.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { createHash } from 'crypto'
import { ulid } from 'ulid'
import {createHash} from 'crypto'
import {ulid} from 'ulid'

import { Entity } from "../entity"
import {Entity, EntityKeys} from "../entity"

import { IdStrategy, DataStructure, StopWordOptions, SchemaOptions } from './options'
import {DataStructure, IdStrategy, SchemaOptions, StopWordOptions} from './options'

import { SchemaDefinition } from './definitions'
import { Field } from './field'
import { InvalidSchema } from '../error'
import {FieldDefinition, SchemaDefinition} from './definitions'
import {Field} from './field'
import {InvalidSchema} from '../error'


/**
Expand All @@ -16,7 +16,17 @@ import { InvalidSchema } from '../error'
* a {@link SchemaDefinition}, and optionally {@link SchemaOptions}:
*
* ```typescript
* const schema = new Schema('foo', {
* interface Foo extends Entity {
* aString: string,
* aNumber: number,
* aBoolean: boolean,
* someText: string,
* aPoint: Point,
* aDate: Date,
* someStrings: string[],
* }
*
* const schema = new Schema<Foo>('foo', {
* aString: { type: 'string' },
* aNumber: { type: 'number' },
* aBoolean: { type: 'boolean' },
Expand All @@ -32,11 +42,11 @@ import { InvalidSchema } from '../error'
* A Schema is primarily used by a {@link Repository} which requires a Schema in
* its constructor.
*/
export class Schema {
export class Schema<T extends Entity = Record<string, any>> {

#schemaName: string
#fieldsByName: Record<string, Field> = {}
#definition: SchemaDefinition
readonly #schemaName: string
#fieldsByName = {} as Record<EntityKeys<T>, Field>;
readonly #definition: SchemaDefinition<T>
#options?: SchemaOptions

/**
Expand All @@ -46,7 +56,7 @@ export class Schema {
* @param schemaDef Defines all of the fields for the Schema and how they are mapped to Redis.
* @param options Additional options for this Schema.
*/
constructor(schemaName: string, schemaDef: SchemaDefinition, options?: SchemaOptions) {
constructor(schemaName: string, schemaDef: SchemaDefinition<T>, options?: SchemaOptions) {
this.#schemaName = schemaName
this.#definition = schemaDef
this.#options = options
Expand Down Expand Up @@ -75,7 +85,7 @@ export class Schema {
* @param name The name of the {@link Field} in this Schema.
* @returns The {@link Field}, or null of not found.
*/
fieldByName(name: string): Field | null {
fieldByName(name: EntityKeys<T>): Field | null {
return this.#fieldsByName[name] ?? null
}

Expand Down Expand Up @@ -110,7 +120,7 @@ export class Schema {
*/
async generateId(): Promise<string> {
const ulidStrategy = () => ulid()
return await (this.#options?.idStrategy ?? ulidStrategy)()
return await (this.#options?.idStrategy ?? ulidStrategy)();
}

/**
Expand All @@ -133,8 +143,9 @@ export class Schema {
}

#createFields() {
return Object.entries(this.#definition).forEach(([fieldName, fieldDef]) => {
const field = new Field(fieldName, fieldDef)
const entries = Object.entries(this.#definition) as [EntityKeys<T>, FieldDefinition][];
return entries.forEach(([fieldName, fieldDef]) => {
const field = new Field(String(fieldName), fieldDef)
this.#validateField(field)
this.#fieldsByName[fieldName] = field
})
Expand Down Expand Up @@ -166,3 +177,5 @@ export class Schema {
throw new InvalidSchema(`The field '${field.name}' is configured with a type of '${field.type}'. This type is only valid with a data structure of 'JSON'.`)
}
}

export type InferSchema<T> = T extends Schema<infer R> ? R : never;
Loading

0 comments on commit 1acd1cf

Please sign in to comment.