diff --git a/docs/caching.md b/docs/caching.md index e0a3c002a9..8501348430 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -189,6 +189,7 @@ In case you want to connect to a redis-cluster using IORedis's cluster functiona ``` Note that, you can still use options as first argument of IORedis's cluster constructor. + ```typescript { ... @@ -213,4 +214,24 @@ Note that, you can still use options as first argument of IORedis's cluster cons } ``` +If none of the built-in cache providers satisfy your demands, then you can also specify your own cache provider by using a `provider` factory function which needs to return a new object that implements the `QueryResultCache` interface: + +```typescript +class CustomQueryResultCache implements QueryResultCache { + constructor(private connection: Connection) {} + ... +} +``` + +```typescript +{ + ... + cache: { + provider(connection) { + return new CustomQueryResultCache(connection); + } + } +} +``` + You can use `typeorm cache:clear` to clear everything stored in the cache. diff --git a/src/cache/QueryResultCacheFactory.ts b/src/cache/QueryResultCacheFactory.ts index 394b3cd66d..d102bdb6cf 100644 --- a/src/cache/QueryResultCacheFactory.ts +++ b/src/cache/QueryResultCacheFactory.ts @@ -26,16 +26,17 @@ export class QueryResultCacheFactory { if (!this.connection.options.cache) throw new Error(`To use cache you need to enable it in connection options by setting cache: true or providing some caching options. Example: { host: ..., username: ..., cache: true }`); - if ((this.connection.options.cache as any).type === "redis") - return new RedisQueryResultCache(this.connection, "redis"); + const cache: any = this.connection.options.cache; - if ((this.connection.options.cache as any).type === "ioredis") - return new RedisQueryResultCache(this.connection, "ioredis"); + if (cache.provider && typeof cache.provider === "function") { + return cache.provider(this.connection); + } - if ((this.connection.options.cache as any).type === "ioredis/cluster") - return new RedisQueryResultCache(this.connection, "ioredis/cluster"); - - return new DbQueryResultCache(this.connection); + if (cache.type === "redis" || cache.type === "ioredis" || cache.type === "ioredis/cluster") { + return new RedisQueryResultCache(this.connection, cache.type); + } else { + return new DbQueryResultCache(this.connection); + } } } diff --git a/src/connection/BaseConnectionOptions.ts b/src/connection/BaseConnectionOptions.ts index 5076dbd847..fa86a33cdd 100644 --- a/src/connection/BaseConnectionOptions.ts +++ b/src/connection/BaseConnectionOptions.ts @@ -3,6 +3,8 @@ import {LoggerOptions} from "../logger/LoggerOptions"; import {NamingStrategyInterface} from "../naming-strategy/NamingStrategyInterface"; import {DatabaseType} from "../driver/types/DatabaseType"; import {Logger} from "../logger/Logger"; +import {Connection} from "./Connection"; +import {QueryResultCache} from "../cache/QueryResultCache"; /** * BaseConnectionOptions is set of connection options shared by all database types. @@ -121,6 +123,11 @@ export interface BaseConnectionOptions { */ readonly type?: "database" | "redis" | "ioredis" | "ioredis/cluster"; // todo: add mongodb and other cache providers as well in the future + /** + * Factory function for custom cache providers that implement QueryResultCache. + */ + readonly provider?: (connection: Connection) => QueryResultCache; + /** * Configurable table name for "database" type cache. * Default value is "query-result-cache" diff --git a/test/functional/cache/custom-cache-provider.ts b/test/functional/cache/custom-cache-provider.ts new file mode 100644 index 0000000000..ab4f110a2c --- /dev/null +++ b/test/functional/cache/custom-cache-provider.ts @@ -0,0 +1,318 @@ +import "reflect-metadata"; +import {expect} from "chai"; +import { + closeTestingConnections, + createTestingConnections, + reloadTestingDatabases, + sleep +} from "../../utils/test-utils"; +import {Connection} from "../../../src/connection/Connection"; +import {User} from "./entity/User"; +import {MockQueryResultCache} from "./provider/MockQueryResultCache"; + +describe("custom cache provider", () => { + + let connections: Connection[]; + before(async () => connections = await createTestingConnections({ + entities: [__dirname + "/entity/*{.js,.ts}"], + cache: { + provider(connection) { + return new MockQueryResultCache(connection); + } + } + })); + beforeEach(() => reloadTestingDatabases(connections)); + after(() => closeTestingConnections(connections)); + + it("should be used instead of built-ins", () => Promise.all(connections.map(async connection => { + const queryResultCache: any = connection.queryResultCache; + expect(queryResultCache).to.have.property("queryResultCacheTable"); + + const queryResultCacheTable = queryResultCache.queryResultCacheTable; + expect(queryResultCacheTable).to.contain("mock"); + }))); + + it("should cache results properly", () => Promise.all(connections.map(async connection => { + + // first prepare data - insert users + const user1 = new User(); + user1.firstName = "Timber"; + user1.lastName = "Saw"; + user1.isAdmin = false; + await connection.manager.save(user1); + + const user2 = new User(); + user2.firstName = "Alex"; + user2.lastName = "Messer"; + user2.isAdmin = false; + await connection.manager.save(user2); + + const user3 = new User(); + user3.firstName = "Umed"; + user3.lastName = "Pleerock"; + user3.isAdmin = true; + await connection.manager.save(user3); + + // select for the first time with caching enabled + const users1 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: true }) + .cache(true) + .getMany(); + expect(users1.length).to.be.equal(1); + + // insert new entity + const user4 = new User(); + user4.firstName = "Bakhrom"; + user4.lastName = "Brochik"; + user4.isAdmin = true; + await connection.manager.save(user4); + + // without cache it must return really how many there entities are + const users2 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: true }) + .getMany(); + expect(users2.length).to.be.equal(2); + + // but with cache enabled it must not return newly inserted entity since cache is not expired yet + const users3 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: true }) + .cache(true) + .getMany(); + expect(users3.length).to.be.equal(1); + + // give some time for cache to expire + await sleep(1000); + + // now, when our cache has expired we check if we have new user inserted even with cache enabled + const users4 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: true }) + .cache(true) + .getMany(); + expect(users4.length).to.be.equal(2); + + }))); + + it("should cache results with pagination enabled properly", () => Promise.all(connections.map(async connection => { + + // first prepare data - insert users + const user1 = new User(); + user1.firstName = "Timber"; + user1.lastName = "Saw"; + user1.isAdmin = false; + await connection.manager.save(user1); + + const user2 = new User(); + user2.firstName = "Alex"; + user2.lastName = "Messer"; + user2.isAdmin = false; + await connection.manager.save(user2); + + const user3 = new User(); + user3.firstName = "Umed"; + user3.lastName = "Pleerock"; + user3.isAdmin = true; + await connection.manager.save(user3); + + // select for the first time with caching enabled + const users1 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: false }) + .skip(1) + .take(5) + .orderBy("user.id") + .cache(true) + .getMany(); + expect(users1.length).to.be.equal(1); + + // insert new entity + const user4 = new User(); + user4.firstName = "Bakhrom"; + user4.lastName = "Bro"; + user4.isAdmin = false; + await connection.manager.save(user4); + + // without cache it must return really how many there entities are + const users2 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: false }) + .skip(1) + .take(5) + .orderBy("user.id") + .getMany(); + expect(users2.length).to.be.equal(2); + + // but with cache enabled it must not return newly inserted entity since cache is not expired yet + const users3 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: false }) + .skip(1) + .take(5) + .cache(true) + .orderBy("user.id") + .getMany(); + expect(users3.length).to.be.equal(1); + + // give some time for cache to expire + await sleep(1000); + + // now, when our cache has expired we check if we have new user inserted even with cache enabled + const users4 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: false }) + .skip(1) + .take(5) + .cache(true) + .orderBy("user.id") + .getMany(); + expect(users4.length).to.be.equal(2); + + }))); + + it("should cache results with custom id and duration supplied", () => Promise.all(connections.map(async connection => { + + // first prepare data - insert users + const user1 = new User(); + user1.firstName = "Timber"; + user1.lastName = "Saw"; + user1.isAdmin = false; + await connection.manager.save(user1); + + const user2 = new User(); + user2.firstName = "Alex"; + user2.lastName = "Messer"; + user2.isAdmin = false; + await connection.manager.save(user2); + + const user3 = new User(); + user3.firstName = "Umed"; + user3.lastName = "Pleerock"; + user3.isAdmin = true; + await connection.manager.save(user3); + + // select for the first time with caching enabled + const users1 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: false }) + .skip(1) + .take(5) + .cache("user_admins", 2000) + .orderBy("user.id") + .getMany(); + expect(users1.length).to.be.equal(1); + + // insert new entity + const user4 = new User(); + user4.firstName = "Bakhrom"; + user4.lastName = "Bro"; + user4.isAdmin = false; + await connection.manager.save(user4); + + // without cache it must return really how many there entities are + const users2 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: false }) + .skip(1) + .take(5) + .orderBy("user.id") + .getMany(); + expect(users2.length).to.be.equal(2); + + // give some time for cache to expire + await sleep(1000); + + // but with cache enabled it must not return newly inserted entity since cache is not expired yet + const users3 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: false }) + .skip(1) + .take(5) + .orderBy("user.id") + .cache("user_admins", 2000) + .getMany(); + expect(users3.length).to.be.equal(1); + + // give some time for cache to expire + await sleep(1000); + + // now, when our cache has expired we check if we have new user inserted even with cache enabled + const users4 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: false }) + .skip(1) + .take(5) + .orderBy("user.id") + .cache("user_admins", 2000) + .getMany(); + expect(users4.length).to.be.equal(2); + + }))); + + it("should cache results with custom id and duration supplied", () => Promise.all(connections.map(async connection => { + + // first prepare data - insert users + const user1 = new User(); + user1.firstName = "Timber"; + user1.lastName = "Saw"; + user1.isAdmin = false; + await connection.manager.save(user1); + + const user2 = new User(); + user2.firstName = "Alex"; + user2.lastName = "Messer"; + user2.isAdmin = false; + await connection.manager.save(user2); + + const user3 = new User(); + user3.firstName = "Umed"; + user3.lastName = "Pleerock"; + user3.isAdmin = true; + await connection.manager.save(user3); + + // select for the first time with caching enabled + const users1 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: true }) + .cache(true) + .getCount(); + expect(users1).to.be.equal(1); + + // insert new entity + const user4 = new User(); + user4.firstName = "Bakhrom"; + user4.lastName = "Brochik"; + user4.isAdmin = true; + await connection.manager.save(user4); + + // without cache it must return really how many there entities are + const users2 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: true }) + .getCount(); + expect(users2).to.be.equal(2); + + // but with cache enabled it must not return newly inserted entity since cache is not expired yet + const users3 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: true }) + .cache(true) + .getCount(); + expect(users3).to.be.equal(1); + + // give some time for cache to expire + await sleep(1000); + + // now, when our cache has expired we check if we have new user inserted even with cache enabled + const users4 = await connection + .createQueryBuilder(User, "user") + .where("user.isAdmin = :isAdmin", { isAdmin: true }) + .cache(true) + .getCount(); + expect(users4).to.be.equal(2); + + }))); + +}); diff --git a/test/functional/cache/entity/User.ts b/test/functional/cache/entity/User.ts new file mode 100644 index 0000000000..124a00c186 --- /dev/null +++ b/test/functional/cache/entity/User.ts @@ -0,0 +1,20 @@ +import {Entity} from "../../../../src/decorator/entity/Entity"; +import {PrimaryGeneratedColumn} from "../../../../src/decorator/columns/PrimaryGeneratedColumn"; +import {Column} from "../../../../src/decorator/columns/Column"; + +@Entity() +export class User { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + firstName: string; + + @Column() + lastName: string; + + @Column() + isAdmin: boolean; + +} \ No newline at end of file diff --git a/test/functional/cache/provider/MockQueryResultCache.ts b/test/functional/cache/provider/MockQueryResultCache.ts new file mode 100644 index 0000000000..1d2993911f --- /dev/null +++ b/test/functional/cache/provider/MockQueryResultCache.ts @@ -0,0 +1,235 @@ +import {ObjectLiteral} from "../../../../src/common/ObjectLiteral"; +import {Connection} from "../../../../src/connection/Connection"; +import {OracleDriver} from "../../../../src/driver/oracle/OracleDriver"; +import {PostgresConnectionOptions} from "../../../../src/driver/postgres/PostgresConnectionOptions"; +import {MssqlParameter} from "../../../../src/driver/sqlserver/MssqlParameter"; +import {SqlServerConnectionOptions} from "../../../../src/driver/sqlserver/SqlServerConnectionOptions"; +import {SqlServerDriver} from "../../../../src/driver/sqlserver/SqlServerDriver"; +import {QueryRunner} from "../../../../src/query-runner/QueryRunner"; +import {Table} from "../../../../src/schema-builder/table/Table"; +import {QueryResultCache} from "../../../../src/cache/QueryResultCache"; +import {QueryResultCacheOptions} from "../../../../src/cache/QueryResultCacheOptions"; + +/** + * Caches query result into current database, into separate table called "mock-query-result-cache". + */ +export class MockQueryResultCache implements QueryResultCache { + + // ------------------------------------------------------------------------- + // Private properties + // ------------------------------------------------------------------------- + + private queryResultCacheTable: string; + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + constructor(protected connection: Connection) { + + const options = this.connection.driver.options; + const cacheOptions = typeof this.connection.options.cache === "object" ? this.connection.options.cache : {}; + const cacheTableName = cacheOptions.tableName || "mock-query-result-cache"; + + this.queryResultCacheTable = this.connection.driver.buildTableName(cacheTableName, options.schema, options.database); + } + + // ------------------------------------------------------------------------- + // Public Methods + // ------------------------------------------------------------------------- + + /** + * Creates a connection with given cache provider. + */ + async connect(): Promise { + } + + /** + * Disconnects with given cache provider. + */ + async disconnect(): Promise { + } + + /** + * Creates table for storing cache if it does not exist yet. + */ + async synchronize(queryRunner?: QueryRunner): Promise { + queryRunner = this.getQueryRunner(queryRunner); + const driver = this.connection.driver; + const tableExist = await queryRunner.hasTable(this.queryResultCacheTable); // todo: table name should be configurable + if (tableExist) + return; + + await queryRunner.createTable(new Table( + { + name: this.queryResultCacheTable, + columns: [ + { + name: "id", + isPrimary: true, + isNullable: false, + type: driver.normalizeType({type: driver.mappedDataTypes.cacheId}), + generationStrategy: "increment", + isGenerated: true + }, + { + name: "identifier", + type: driver.normalizeType({type: driver.mappedDataTypes.cacheIdentifier}), + isNullable: true + }, + { + name: "time", + type: driver.normalizeType({type: driver.mappedDataTypes.cacheTime}), + isPrimary: false, + isNullable: false + }, + { + name: "duration", + type: driver.normalizeType({type: driver.mappedDataTypes.cacheDuration}), + isPrimary: false, + isNullable: false + }, + { + name: "query", + type: driver.normalizeType({type: driver.mappedDataTypes.cacheQuery}), + isPrimary: false, + isNullable: false + }, + { + name: "result", + type: driver.normalizeType({type: driver.mappedDataTypes.cacheResult}), + isNullable: false + }, + ] + }, + )); + } + + /** + * Caches given query result. + * Returns cache result if found. + * Returns undefined if result is not cached. + */ + getFromCache(options: QueryResultCacheOptions, queryRunner?: QueryRunner): Promise { + queryRunner = this.getQueryRunner(queryRunner); + const qb = this.connection + .createQueryBuilder(queryRunner) + .select() + .from(this.queryResultCacheTable, "cache"); + + if (options.identifier) { + return qb + .where(`${qb.escape("cache")}.${qb.escape("identifier")} = :identifier`) + .setParameters({ identifier: this.connection.driver instanceof SqlServerDriver ? new MssqlParameter(options.identifier, "nvarchar") : options.identifier }) + .getRawOne(); + + } else if (options.query) { + if (this.connection.driver instanceof OracleDriver) { + return qb + .where(`dbms_lob.compare(${qb.escape("cache")}.${qb.escape("query")}, :query) = 0`, { query: options.query }) + .getRawOne(); + } + + return qb + .where(`${qb.escape("cache")}.${qb.escape("query")} = :query`) + .setParameters({ query: this.connection.driver instanceof SqlServerDriver ? new MssqlParameter(options.query, "nvarchar") : options.query }) + .getRawOne(); + } + + return Promise.resolve(undefined); + } + + /** + * Checks if cache is expired or not. + */ + isExpired(savedCache: QueryResultCacheOptions): boolean { + const duration = typeof savedCache.duration === "string" ? parseInt(savedCache.duration) : savedCache.duration; + return ((typeof savedCache.time === "string" ? parseInt(savedCache.time as any) : savedCache.time)! + duration) < new Date().getTime(); + } + + /** + * Stores given query result in the cache. + */ + async storeInCache(options: QueryResultCacheOptions, savedCache: QueryResultCacheOptions|undefined, queryRunner?: QueryRunner): Promise { + queryRunner = this.getQueryRunner(queryRunner); + + let insertedValues: ObjectLiteral = options; + if (this.connection.driver instanceof SqlServerDriver) { // todo: bad abstraction, re-implement this part, probably better if we create an entity metadata for cache table + insertedValues = { + identifier: new MssqlParameter(options.identifier, "nvarchar"), + time: new MssqlParameter(options.time, "bigint"), + duration: new MssqlParameter(options.duration, "int"), + query: new MssqlParameter(options.query, "nvarchar"), + result: new MssqlParameter(options.result, "nvarchar"), + }; + } + + if (savedCache && savedCache.identifier) { // if exist then update + const qb = queryRunner.manager + .createQueryBuilder() + .update(this.queryResultCacheTable) + .set(insertedValues); + + qb.where(`${qb.escape("identifier")} = :condition`, { condition: insertedValues.identifier }); + await qb.execute(); + + } else if (savedCache && savedCache.query) { // if exist then update + const qb = queryRunner.manager + .createQueryBuilder() + .update(this.queryResultCacheTable) + .set(insertedValues); + + if (this.connection.driver instanceof OracleDriver) { + qb.where(`dbms_lob.compare("query", :condition) = 0`, { condition: insertedValues.query }); + + } else { + qb.where(`${qb.escape("query")} = :condition`, { condition: insertedValues.query }); + } + + await qb.execute(); + + } else { // otherwise insert + await queryRunner.manager + .createQueryBuilder() + .insert() + .into(this.queryResultCacheTable) + .values(insertedValues) + .execute(); + } + } + + /** + * Clears everything stored in the cache. + */ + async clear(queryRunner: QueryRunner): Promise { + return this.getQueryRunner(queryRunner).clearTable(this.queryResultCacheTable); + } + + /** + * Removes all cached results by given identifiers from cache. + */ + async remove(identifiers: string[], queryRunner?: QueryRunner): Promise { + await Promise.all(identifiers.map(identifier => { + const qb = this.getQueryRunner(queryRunner).manager.createQueryBuilder(); + return qb.delete() + .from(this.queryResultCacheTable) + .where(`${qb.escape("identifier")} = :identifier`, {identifier}) + .execute(); + })); + } + + // ------------------------------------------------------------------------- + // Protected Methods + // ------------------------------------------------------------------------- + + /** + * Gets a query runner to work with. + */ + protected getQueryRunner(queryRunner: QueryRunner|undefined): QueryRunner { + if (queryRunner) + return queryRunner; + + return this.connection.createQueryRunner("master"); + } + +} diff --git a/test/utils/test-utils.ts b/test/utils/test-utils.ts index 2ed50c8106..9d7298a6b1 100644 --- a/test/utils/test-utils.ts +++ b/test/utils/test-utils.ts @@ -7,6 +7,7 @@ import {EntitySchema} from "../../src/entity-schema/EntitySchema"; import {createConnections} from "../../src/index"; import {NamingStrategyInterface} from "../../src/naming-strategy/NamingStrategyInterface"; import {PromiseUtils} from "../../src/util/PromiseUtils"; +import {QueryResultCache} from "../../src/cache/QueryResultCache"; /** * Interface in which data is stored in ormconfig.json of the project. @@ -101,7 +102,12 @@ export interface TestingOptions { * - "mongodb" means cached values will be stored in mongodb database. You must provide mongodb connection options. * - "redis" means cached values will be stored inside redis. You must provide redis connection options. */ - type?: "database" | "redis"; + readonly type?: "database" | "redis" | "ioredis" | "ioredis/cluster"; // todo: add mongodb and other cache providers as well in the future + + /** + * Factory function for custom cache providers that implement QueryResultCache. + */ + readonly provider?: (connection: Connection) => QueryResultCache; /** * Used to provide mongodb / redis connection options.