Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generic Types for stricter type safety, and search #239

Merged
merged 5 commits into from
Jul 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading