added query builder result cache functionality

This commit is contained in:
Umed Khudoiberdiev 2017-09-11 18:21:06 +05:00
parent b51e199a4a
commit 838b336c4e
28 changed files with 2267 additions and 1054 deletions

View File

@ -51,4 +51,11 @@ services:
image: "mongo:3.4.1"
container_name: "typeorm-mongodb"
ports:
- "27017:27017"
- "27017:27017"
# redis
# redis:
# image: "redis:3.0.3"
# container_name: "typeorm-redis"
# ports:
# - "6379:6379"

View File

@ -24,6 +24,7 @@
* Set locking
* Partial selection
* Using subqueries
* Caching queries
* Building `INSERT` query
* Building `UPDATE` query
* Building `DELETE` query
@ -934,6 +935,108 @@ const posts = await connection
.getRawMany();
```
### Caching queries
You can cache results of `getMany`, `getOne`, `getRawMany`, `getRawOne` and `getCount` methods.
To enable caching you need to explicitly enable it in connection options:
```typescript
{
type: "mysql",
host: "localhost",
username: "test",
...
cache: true
}
```
When you enable cache for the first time,
you must synchronize your database schema (using cli, migrations or simply option in connection).
Then in `QueryBuilder` you can enable query cache for any query:
```typescript
const users = await connection
.createQueryBuilder(User, "user")
.where("user.isAdmin = :isAdmin", { isAdmin: true })
.cache(true)
.getMany();
```
This will execute query to fetch all admin users and cache its result.
Next time when you execute same code it will get admin users from cache.
Default cache time is equal to `1000 ms`, e.g. 1 second.
It means cache will be invalid in 1 second after you first time call query builder code.
In practice it means if users open user page 150 times within 3 seconds only three queries will be executed during this period.
All other inserted users during 1 second of caching won't be returned to user.
You can change cache time manually:
```typescript
const users = await connection
.createQueryBuilder(User, "user")
.where("user.isAdmin = :isAdmin", { isAdmin: true })
.cache(60000) // 1 minute
.getMany();
```
Or globally in connection options:
```typescript
{
type: "mysql",
host: "localhost",
username: "test",
...
cache: {
duration: 30000 // 30 seconds
}
}
```
Also you can set a "cache id":
```typescript
const users = await connection
.createQueryBuilder(User, "user")
.where("user.isAdmin = :isAdmin", { isAdmin: true })
.cache("users_admins", 25000)
.getMany();
```
It will allow you to granular control your cache,
for example clear cached results when you insert a new user:
```typescript
await connection.queryResultCache.remove(["users_admins"]);
```
By default, TypeORM uses separate table called `query-result-cache` and stores all queries and results there.
If storing cache in a single database table is not effective for you,
you can change cache type to "redis" and TypeORM will store all cache records in redis instead.
Example:
```typescript
{
type: "mysql",
host: "localhost",
username: "test",
...
cache: {
type: "redis",
options: {
host: "localhost",
port: 6379
}
}
}
```
"options" are [redis specific options](https://github.com/NodeRedis/node_redis#options-object-properties).
You can use `typeorm cache:clear` command to clear everything stored in cache.
## Building `INSERT` query
You can create `INSERT` queries using `QueryBuilder`.

2061
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -62,10 +62,11 @@
"mysql": "^2.14.1",
"mysql2": "^1.4.1",
"pg": "^7.3.0",
"redis": "^2.8.0",
"remap-istanbul": "^0.9.5",
"sinon": "^2.4.1",
"sinon-chai": "^2.13.0",
"sqlite3": "^3.1.8",
"sqlite3": "^3.1.10",
"ts-node": "^3.3.0",
"tslint": "^5.6.0",
"tslint-stylish": "^2.1.0",

169
src/cache/DbQueryResultCache.ts vendored Normal file
View File

@ -0,0 +1,169 @@
import {QueryResultCache} from "./QueryResultCache";
import {QueryResultCacheOptions} from "./QueryResultCacheOptions";
import {TableSchema} from "../schema-builder/schema/TableSchema";
import {ColumnSchema} from "../schema-builder/schema/ColumnSchema";
import {QueryRunner} from "../query-runner/QueryRunner";
import {Connection} from "../connection/Connection";
/**
* Caches query result into current database, into separate table called "query-result-cache".
*/
export class DbQueryResultCache implements QueryResultCache {
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
constructor(protected connection: Connection) {
}
// -------------------------------------------------------------------------
// Public Methods
// -------------------------------------------------------------------------
/**
* Creates a connection with given cache provider.
*/
async connect(): Promise<void> {
}
/**
* Disconnects with given cache provider.
*/
async disconnect(): Promise<void> {
}
/**
* Creates table for storing cache if it does not exist yet.
*/
async synchronize(queryRunner?: QueryRunner): Promise<void> {
queryRunner = this.getQueryRunner(queryRunner);
const driver = this.connection.driver;
const tableExist = await queryRunner.hasTable("query-result-cache"); // todo: table name should be configurable
if (tableExist)
return;
await queryRunner.createTable(new TableSchema("query-result-cache", [ // createTableIfNotExist
new ColumnSchema({
name: "id",
isNullable: true,
isPrimary: true,
type: driver.normalizeType({ type: driver.mappedDataTypes.cacheId }),
generationStrategy: "increment",
isGenerated: true
}),
new ColumnSchema({
name: "identifier",
type: driver.normalizeType({ type: driver.mappedDataTypes.cacheIdentifier }),
isNullable: true,
isUnique: true
}),
new ColumnSchema({
name: "time",
type: driver.normalizeType({ type: driver.mappedDataTypes.cacheTime }),
isPrimary: false,
isNullable: false
}),
new ColumnSchema({
name: "duration",
type: driver.normalizeType({ type: driver.mappedDataTypes.cacheDuration }),
isPrimary: false,
isNullable: false
}),
new ColumnSchema({
name: "query",
type: driver.normalizeType({ type: driver.mappedDataTypes.cacheQuery }),
isPrimary: false,
isNullable: false
}),
new ColumnSchema({
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<QueryResultCacheOptions|undefined> {
queryRunner = this.getQueryRunner(queryRunner);
const qb = this.connection
.createQueryBuilder(queryRunner)
.select()
.from("query-result-cache", "cache");
if (options.identifier) {
return qb
.where(`${qb.escape("cache")}.${qb.escape("identifier")} = :identifier`)
.setParameters({ identifier: options.identifier })
.getRawOne();
} else if (options.query) {
return qb
.where(`${qb.escape("cache")}.${qb.escape("query")} = :query`)
.setParameters({ query: options.query })
.getRawOne();
}
return Promise.resolve(undefined);
}
/**
* Checks if cache is expired or not.
*/
isExpired(savedCache: QueryResultCacheOptions): boolean {
return (savedCache.time! + savedCache.duration) < new Date().getTime();
}
/**
* Stores given query result in the cache.
*/
async storeInCache(options: QueryResultCacheOptions, savedCache: QueryResultCacheOptions|undefined, queryRunner?: QueryRunner): Promise<void> {
queryRunner = this.getQueryRunner(queryRunner);
if (savedCache && savedCache.identifier) { // if exist then update
await queryRunner.update("query-result-cache", options, { identifier: options.identifier });
} else if (savedCache && savedCache.query) { // if exist then update
await queryRunner.update("query-result-cache", options, { query: options.query });
} else { // otherwise insert
await queryRunner.insert("query-result-cache", options);
}
}
/**
* Clears everything stored in the cache.
*/
async clear(queryRunner: QueryRunner): Promise<void> {
return this.getQueryRunner(queryRunner).truncate("query-result-cache");
}
/**
* Removes all cached results by given identifiers from cache.
*/
async remove(identifiers: string[], queryRunner?: QueryRunner): Promise<void> {
await Promise.all(identifiers.map(identifier => {
return this.getQueryRunner(queryRunner).delete("query-result-cache", { identifier });
}));
}
// -------------------------------------------------------------------------
// Protected Methods
// -------------------------------------------------------------------------
/**
* Gets a query runner to work with.
*/
protected getQueryRunner(queryRunner: QueryRunner|undefined): QueryRunner {
if (queryRunner)
return queryRunner;
return this.connection.createQueryRunner("master");
}
}

49
src/cache/QueryResultCache.ts vendored Normal file
View File

@ -0,0 +1,49 @@
import {QueryResultCacheOptions} from "./QueryResultCacheOptions";
import {QueryRunner} from "../query-runner/QueryRunner";
/**
* Implementations of this interface provide different strategies to cache query builder results.
*/
export interface QueryResultCache {
/**
* Creates a connection with given cache provider.
*/
connect(): Promise<void>;
/**
* Closes a connection with given cache provider.
*/
disconnect(): Promise<void>;
/**
* Performs operations needs to be created during schema synchronization.
*/
synchronize(queryRunner?: QueryRunner): Promise<void>;
/**
* Caches given query result.
*/
getFromCache(options: QueryResultCacheOptions, queryRunner?: QueryRunner): Promise<QueryResultCacheOptions|undefined>;
/**
* Stores given query result in the cache.
*/
storeInCache(options: QueryResultCacheOptions, savedCache: QueryResultCacheOptions|undefined, queryRunner?: QueryRunner): Promise<void>;
/**
* Checks if cache is expired or not.
*/
isExpired(savedCache: QueryResultCacheOptions): boolean;
/**
* Clears everything stored in the cache.
*/
clear(queryRunner?: QueryRunner): Promise<void>;
/**
* Removes all cached results by given identifiers from cache.
*/
remove(identifiers: string[], queryRunner?: QueryRunner): Promise<void>;
}

36
src/cache/QueryResultCacheFactory.ts vendored Normal file
View File

@ -0,0 +1,36 @@
import {RedisQueryResultCache} from "./RedisQueryResultCache";
import {DbQueryResultCache} from "./DbQueryResultCache";
import {QueryResultCache} from "./QueryResultCache";
import {Connection} from "../connection/Connection";
/**
* Caches query result into Redis database.
*/
export class QueryResultCacheFactory {
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
constructor(protected connection: Connection) {
}
// -------------------------------------------------------------------------
// Public Methods
// -------------------------------------------------------------------------
/**
* Creates a new query result cache based on connection options.
*/
create(): QueryResultCache {
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);
return new DbQueryResultCache(this.connection);
}
}

32
src/cache/QueryResultCacheOptions.ts vendored Normal file
View File

@ -0,0 +1,32 @@
/**
* Options passed to QueryResultCache class.
*/
export interface QueryResultCacheOptions {
/**
* Cache identifier set by user.
* Can be empty.
*/
identifier: string;
/**
* Time, when cache was created.
*/
time?: number;
/**
* Duration in milliseconds during which results will be returned from cache.
*/
duration: number;
/**
* Cached query.
*/
query: string;
/**
* Query result that will be cached.
*/
result?: any;
}

172
src/cache/RedisQueryResultCache.ts vendored Normal file
View File

@ -0,0 +1,172 @@
import {QueryResultCache} from "./QueryResultCache";
import {QueryResultCacheOptions} from "./QueryResultCacheOptions";
import {PlatformTools} from "../platform/PlatformTools";
import {Connection} from "../connection/Connection";
import {QueryRunner} from "../query-runner/QueryRunner";
/**
* Caches query result into Redis database.
*/
export class RedisQueryResultCache implements QueryResultCache {
// -------------------------------------------------------------------------
// Protected Properties
// -------------------------------------------------------------------------
/**
* Redis module instance loaded dynamically.
*/
protected redis: any;
/**
* Connected redis client.
*/
protected client: any;
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
constructor(protected connection: Connection) {
this.redis = this.loadRedis();
}
// -------------------------------------------------------------------------
// Public Methods
// -------------------------------------------------------------------------
/**
* Creates a connection with given cache provider.
*/
async connect(): Promise<void> {
const cacheOptions: any = this.connection.options.cache;
if (cacheOptions && cacheOptions.options) {
this.client = this.redis.createClient(cacheOptions.options);
} else {
this.client = this.redis.createClient();
}
}
/**
* Creates a connection with given cache provider.
*/
async disconnect(): Promise<void> {
return new Promise<void>((ok, fail) => {
this.client.quit((err: any, result: any) => {
if (err) return fail(err);
ok();
this.client = undefined;
});
});
}
/**
* Creates table for storing cache if it does not exist yet.
*/
async synchronize(queryRunner: QueryRunner): Promise<void> {
}
/**
* Caches given query result.
* Returns cache result if found.
* Returns undefined if result is not cached.
*/
getFromCache(options: QueryResultCacheOptions, queryRunner?: QueryRunner): Promise<QueryResultCacheOptions|undefined> {
return new Promise((ok, fail) => {
if (options.identifier) {
this.client.get(options.identifier, (err: any, result: any) => {
if (err) return fail(err);
ok(JSON.parse(result));
});
} else if (options.query) {
this.client.get(options.query, (err: any, result: any) => {
if (err) return fail(err);
ok(JSON.parse(result));
});
} else {
ok(undefined);
}
});
}
/**
* Checks if cache is expired or not.
*/
isExpired(savedCache: QueryResultCacheOptions): boolean {
return (savedCache.time! + savedCache.duration) < new Date().getTime();
}
/**
* Stores given query result in the cache.
*/
async storeInCache(options: QueryResultCacheOptions, savedCache: QueryResultCacheOptions, queryRunner?: QueryRunner): Promise<void> {
return new Promise<void>((ok, fail) => {
if (options.identifier) {
this.client.set(options.identifier, JSON.stringify(options), (err: any, result: any) => {
if (err) return fail(err);
ok();
});
} else if (options.query) {
this.client.set(options.query, JSON.stringify(options), (err: any, result: any) => {
if (err) return fail(err);
ok();
});
}
});
}
/**
* Clears everything stored in the cache.
*/
async clear(queryRunner?: QueryRunner): Promise<void> {
return new Promise<void>((ok, fail) => {
this.client.flushdb((err: any, result: any) => {
if (err) return fail(err);
ok();
});
});
}
/**
* Removes all cached results by given identifiers from cache.
*/
async remove(identifiers: string[], queryRunner?: QueryRunner): Promise<void> {
await Promise.all(identifiers.map(identifier => {
return this.deleteKey(identifier);
}));
}
// -------------------------------------------------------------------------
// Protected Methods
// -------------------------------------------------------------------------
/**
* Removes a single key from redis database.
*/
protected deleteKey(key: string): Promise<void> {
return new Promise<void>((ok, fail) => {
this.client.del(key, (err: any, result: any) => {
if (err) return fail(err);
ok();
});
});
}
/**
* Loads redis dependency.
*/
protected loadRedis(): any {
try {
return PlatformTools.load("redis");
} catch (e) {
throw new Error(`Cannot use cache because redis is not installed. Please run "npm i redis --save".`);
}
}
}

View File

@ -0,0 +1,60 @@
import {createConnection} from "../index";
import {ConnectionOptionsReader} from "../connection/ConnectionOptionsReader";
import {Connection} from "../connection/Connection";
const chalk = require("chalk");
/**
* Clear cache command.
*/
export class CacheClearCommand {
command = "cache:clear";
describe = "Clears all data stored in query runner cache.";
builder(yargs: any) {
return yargs
.option("c", {
alias: "connection",
default: "default",
describe: "Name of the connection on which run a query."
})
.option("cf", {
alias: "config",
default: "ormconfig",
describe: "Name of the file with connection configuration."
});
}
async handler(argv: any) {
let connection: Connection|undefined = undefined;
try {
const connectionOptionsReader = new ConnectionOptionsReader({ root: process.cwd(), configName: argv.config });
const connectionOptions = await connectionOptionsReader.get(argv.connection);
Object.assign(connectionOptions, {
subscribers: [],
dropSchemaOnConnection: false,
autoSchemaSync: false,
autoMigrationsRun: false,
logging: { logQueries: false, logFailedQueryError: false, logSchemaCreation: true }
});
connection = await createConnection(connectionOptions);
if (!connection.queryResultCache)
return console.log(chalk.black.bgRed("Cache is not enabled. To use cache enable it in connection configuration."));
await connection.queryResultCache.clear();
console.log(chalk.green("Cache was successfully cleared"));
} catch (err) {
console.log(chalk.black.bgRed("Error during cache clear:"));
console.error(err);
// throw err;
} finally {
if (connection)
await connection.close();
}
}
}

View File

@ -104,6 +104,38 @@ export interface BaseConnectionOptions {
*/
readonly extra?: any;
/**
* Allows to setup cache options.
*/
readonly cache?: boolean|{
/**
* Type of caching.
*
* - "database" means cached values will be stored in the separate table in database. This is default value.
* - "redis" means cached values will be stored inside redis. You must provide redis connection options.
*/
readonly type?: "database"|"redis"; // todo: add mongodb and other cache providers as well in the future
/**
* Used to provide redis connection options.
*/
readonly options?: any;
/**
* If set to true then queries (using find methods and QueryBuilder's methods) will always be cached.
*/
readonly alwaysEnabled?: boolean;
/**
* Time in milliseconds in which cache will expire.
* This can be setup per-query.
* Default value is 1000 which is equivalent to 1 second.
*/
readonly duration?: number;
};
/**
* CLI settings.
*/

View File

@ -25,6 +25,8 @@ import {ConnectionMetadataBuilder} from "./ConnectionMetadataBuilder";
import {QueryRunner} from "../query-runner/QueryRunner";
import {SelectQueryBuilder} from "../query-builder/SelectQueryBuilder";
import {LoggerFactory} from "../logger/LoggerFactory";
import {QueryResultCacheFactory} from "../cache/QueryResultCacheFactory";
import {QueryResultCache} from "../cache/QueryResultCache";
/**
* Connection is a single database ORM connection to a specific database.
@ -87,6 +89,11 @@ export class Connection {
*/
readonly entityMetadatas: EntityMetadata[] = [];
/**
* Used to work with query result cache.
*/
readonly queryResultCache?: QueryResultCache;
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
@ -98,6 +105,7 @@ export class Connection {
this.driver = new DriverFactory().create(this);
this.manager = new EntityManagerFactory().create(this);
this.namingStrategy = options.namingStrategy || new DefaultNamingStrategy();
this.queryResultCache = options.cache ? new QueryResultCacheFactory(this).create() : undefined;
}
// -------------------------------------------------------------------------
@ -134,6 +142,10 @@ export class Connection {
// connect to the database via its driver
await this.driver.connect();
// connect to the cache-specific database if cache is enabled
if (this.queryResultCache)
await this.queryResultCache.connect();
// set connected status for the current connection
Object.assign(this, { isConnected: true });
@ -176,6 +188,11 @@ export class Connection {
throw new CannotExecuteNotConnectedError(this.name);
await this.driver.disconnect();
// disconnect from the cache-specific database if cache was enabled
if (this.queryResultCache)
await this.queryResultCache.disconnect();
Object.assign(this, { isConnected: false });
}

View File

@ -70,6 +70,12 @@ export class MongoDriver implements Driver {
treeLevel: "int",
migrationName: "int",
migrationTimestamp: "int",
cacheId: "int",
cacheIdentifier: "int",
cacheTime: "int",
cacheDuration: "int",
cacheQuery: "int",
cacheResult: "int",
};
/**

View File

@ -119,7 +119,13 @@ export class MysqlDriver implements Driver {
version: "int",
treeLevel: "int",
migrationName: "varchar",
migrationTimestamp: "bigint"
migrationTimestamp: "bigint",
cacheId: "int",
cacheIdentifier: "varchar",
cacheTime: "bigint",
cacheDuration: "int",
cacheQuery: "text",
cacheResult: "text",
};
/**

View File

@ -120,8 +120,14 @@ export class OracleDriver implements Driver {
updateDateDefault: "CURRENT_TIMESTAMP",
version: "number",
treeLevel: "number",
migrationName: "varchar",
migrationName: "nvarchar",
migrationTimestamp: "timestamp",
cacheId: "int",
cacheIdentifier: "nvarchar",
cacheTime: "timestamp",
cacheDuration: "int",
cacheQuery: "text",
cacheResult: "text",
};
/**

View File

@ -140,6 +140,12 @@ export class PostgresDriver implements Driver {
treeLevel: "int",
migrationName: "varchar",
migrationTimestamp: "bigint",
cacheId: "int",
cacheIdentifier: "varchar",
cacheTime: "bigint",
cacheDuration: "int",
cacheQuery: "text",
cacheResult: "text",
};
/**

View File

@ -119,6 +119,12 @@ export class AbstractSqliteDriver implements Driver {
treeLevel: "integer",
migrationName: "varchar",
migrationTimestamp: "bigint",
cacheId: "int",
cacheIdentifier: "varchar",
cacheTime: "bigint",
cacheDuration: "int",
cacheQuery: "text",
cacheResult: "text",
};
/**

View File

@ -126,6 +126,12 @@ export class SqlServerDriver implements Driver {
treeLevel: "int",
migrationName: "varchar",
migrationTimestamp: "bigint",
cacheId: "int",
cacheIdentifier: "varchar",
cacheTime: "bigint",
cacheDuration: "int",
cacheQuery: "text",
cacheResult: "text",
};
/**

View File

@ -56,4 +56,34 @@ export interface MappedColumnTypes {
*/
migrationName: ColumnType;
/**
* Column type for identifier column in query result cache table.
*/
cacheId: ColumnType;
/**
* Column type for identifier column in query result cache table.
*/
cacheIdentifier: ColumnType;
/**
* Column type for time column in query result cache table.
*/
cacheTime: ColumnType;
/**
* Column type for duration column in query result cache table.
*/
cacheDuration: ColumnType;
/**
* Column type for query column in query result cache table.
*/
cacheQuery: ColumnType;
/**
* Column type for result column in query result cache table.
*/
cacheResult: ColumnType;
}

View File

@ -293,9 +293,9 @@ export abstract class QueryBuilder<Entity> {
}
/**
* Gets sql to be executed with all parameters used in it.
* Gets query to be executed with all parameters used in it.
*/
getSqlAndParameters(): [string, any[]] {
getQueryAndParameters(): [string, any[]] {
return this.connection.driver.escapeQueryWithParameters(this.getQuery(), this.getParameters());
}
@ -303,7 +303,7 @@ export abstract class QueryBuilder<Entity> {
* Executes sql generated by query builder and returns raw database results.
*/
async execute(): Promise<any> {
const [sql, parameters] = this.getSqlAndParameters();
const [sql, parameters] = this.getQueryAndParameters();
const queryRunner = this.obtainQueryRunner();
try {
return await queryRunner.query(sql, parameters); // await is needed here because we are using finally

View File

@ -166,6 +166,23 @@ export class QueryExpressionMap {
*/
aliasNamePrefixingEnabled: boolean = true;
/**
* Indicates if query result cache is enabled or not.
*/
cache: boolean = false;
/**
* Time in milliseconds in which cache will expire.
* If not set then global caching time will be used.
*/
cacheDuration: number;
/**
* Cache id.
* Used to identifier your cache queries.
*/
cacheId: string;
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
@ -287,6 +304,12 @@ export class QueryExpressionMap {
map.disableEscaping = this.disableEscaping;
map.ignoreParentTablesJoins = this.ignoreParentTablesJoins;
map.enableRelationIdValues = this.enableRelationIdValues;
map.extraAppendedAndWhereCondition = this.extraAppendedAndWhereCondition;
map.subQuery = this.subQuery;
map.aliasNamePrefixingEnabled = this.aliasNamePrefixingEnabled;
map.cache = this.cache;
map.cacheId = this.cacheId;
map.cacheDuration = this.cacheDuration;
return map;
}

View File

@ -29,6 +29,7 @@ import {QueryRunner} from "../query-runner/QueryRunner";
import {WhereExpression} from "./WhereExpression";
import {Brackets} from "./Brackets";
import {SqliteDriver} from "../driver/sqlite/SqliteDriver";
import {QueryResultCacheOptions} from "../cache/QueryResultCacheOptions";
/**
* Allows to build complex sql queries in a fashion way and execute those queries.
@ -892,13 +893,7 @@ export class SelectQueryBuilder<Entity> extends QueryBuilder<Entity> implements
* Gets first raw result returned by execution of generated query builder sql.
*/
async getRawOne(): Promise<any> {
if (this.expressionMap.lockMode === "optimistic")
throw new OptimisticLockCanNotBeUsedError();
this.expressionMap.queryEntity = false;
const results = await this.execute();
return results[0];
return (await this.getRawMany())[0];
}
/**
@ -909,7 +904,15 @@ export class SelectQueryBuilder<Entity> extends QueryBuilder<Entity> implements
throw new OptimisticLockCanNotBeUsedError();
this.expressionMap.queryEntity = false;
return this.execute();
const queryRunner = this.obtainQueryRunner();
try {
return await this.loadRawResults(queryRunner);
} finally {
if (queryRunner !== this.queryRunner) { // means we created our own query runner
await queryRunner.release();
}
}
}
/**
@ -1006,7 +1009,7 @@ export class SelectQueryBuilder<Entity> extends QueryBuilder<Entity> implements
*/
async stream(): Promise<ReadStream> {
this.expressionMap.queryEntity = false;
const [sql, parameters] = this.getSqlAndParameters();
const [sql, parameters] = this.getQueryAndParameters();
const queryRunner = this.obtainQueryRunner();
try {
const releaseFn = () => {
@ -1022,6 +1025,46 @@ export class SelectQueryBuilder<Entity> extends QueryBuilder<Entity> implements
}
}
/**
* Enables or disables query result caching.
*/
cache(enabled: boolean): this;
/**
* Enables query result caching and sets in milliseconds in which cache will expire.
* If not set then global caching time will be used.
*/
cache(milliseconds: number): this;
/**
* Enables query result caching and sets cache id and milliseconds in which cache will expire.
*/
cache(id: any, milliseconds?: number): this;
/**
* Enables or disables query result caching.
*/
cache(enabledOrMillisecondsOrId: boolean|number|string, maybeMilliseconds?: number): this {
if (typeof enabledOrMillisecondsOrId === "boolean") {
this.expressionMap.cache = enabledOrMillisecondsOrId;
} else if (typeof enabledOrMillisecondsOrId === "number") {
this.expressionMap.cache = true;
this.expressionMap.cacheDuration = enabledOrMillisecondsOrId;
} else if (typeof enabledOrMillisecondsOrId === "string" || typeof enabledOrMillisecondsOrId === "number") {
this.expressionMap.cache = true;
this.expressionMap.cacheId = enabledOrMillisecondsOrId;
}
if (maybeMilliseconds) {
this.expressionMap.cacheDuration = maybeMilliseconds;
}
return this;
}
// -------------------------------------------------------------------------
// Protected Methods
// -------------------------------------------------------------------------
@ -1442,16 +1485,15 @@ export class SelectQueryBuilder<Entity> extends QueryBuilder<Entity> implements
}).join(", ") + ")) as \"cnt\"";
}
const [countQuerySql, countQueryParameters] = this.clone()
const results = await this.clone()
.mergeExpressionMap({ ignoreParentTablesJoins: true })
.orderBy()
.groupBy()
.offset(undefined)
.limit(undefined)
.select(countSql)
.getSqlAndParameters();
.loadRawResults(queryRunner);
const results = await queryRunner.query(countQuerySql, countQueryParameters);
if (!results || !results[0] || !results[0]["cnt"])
return 0;
@ -1508,10 +1550,11 @@ export class SelectQueryBuilder<Entity> extends QueryBuilder<Entity> implements
rawResults = await new SelectQueryBuilder(this.connection, queryRunner)
.select(`DISTINCT ${querySelects.join(", ")} `)
.addSelect(selects)
.from(`(${this.clone().orderBy().groupBy().orderBy().getQuery()})`, "distinctAlias")
.from(`(${this.clone().orderBy().groupBy().getQuery()})`, "distinctAlias")
.offset(this.expressionMap.skip)
.limit(this.expressionMap.take)
.orderBy(orderBys)
.cache(this.expressionMap.cache ? this.expressionMap.cache : this.expressionMap.cacheId, this.expressionMap.cacheDuration)
.setParameters(this.getParameters())
.getRawMany();
@ -1539,12 +1582,11 @@ export class SelectQueryBuilder<Entity> extends QueryBuilder<Entity> implements
rawResults = await this.clone()
.mergeExpressionMap({ extraAppendedAndWhereCondition: condition })
.setParameters(parameters)
.getRawMany();
.loadRawResults(queryRunner);
}
} else {
const [sql, parameters] = this.getSqlAndParameters();
rawResults = await queryRunner.query(sql, parameters);
rawResults = await this.loadRawResults(queryRunner);
}
if (rawResults.length > 0) {
@ -1595,6 +1637,38 @@ export class SelectQueryBuilder<Entity> extends QueryBuilder<Entity> implements
return [selectString, orderByObject];
}
/**
* Loads raw results from the database.
*/
protected async loadRawResults(queryRunner: QueryRunner) {
const [sql, parameters] = this.getQueryAndParameters();
const cacheOptions = typeof this.connection.options.cache === "object" ? this.connection.options.cache : {};
let savedQueryResultCacheOptions: QueryResultCacheOptions|undefined = undefined;
if (this.connection.queryResultCache && (this.expressionMap.cache || cacheOptions.alwaysEnabled)) {
savedQueryResultCacheOptions = await this.connection.queryResultCache.getFromCache({
identifier: this.expressionMap.cacheId,
query: this.getSql(),
duration: this.expressionMap.cacheDuration || cacheOptions.duration || 1000
}, queryRunner);
if (savedQueryResultCacheOptions && !this.connection.queryResultCache.isExpired(savedQueryResultCacheOptions))
return JSON.parse(savedQueryResultCacheOptions.result);
}
const results = await queryRunner.query(sql, parameters);
if (this.connection.queryResultCache && (this.expressionMap.cache || cacheOptions.alwaysEnabled)) {
await this.connection.queryResultCache.storeInCache({
identifier: this.expressionMap.cacheId,
query: this.getSql(),
time: new Date().getTime(),
duration: this.expressionMap.cacheDuration || cacheOptions.duration || 1000,
result: JSON.stringify(results)
}, savedQueryResultCacheOptions, queryRunner);
}
return results;
}
/**
* Merges into expression map given expression map properties.
*/

View File

@ -139,6 +139,8 @@ export interface QueryRunner {
*/
createTable(table: TableSchema): Promise<void>;
// todo: create createTableIfNotExist method
/**
* Drops the table.
*/

View File

@ -63,6 +63,11 @@ export class RdbmsSchemaBuilder implements SchemaBuilder {
await this.queryRunner.startTransaction();
try {
await this.executeSchemaSyncOperationsInProperOrder();
// if cache is enabled then perform cache-synchronization as well
if (this.connection.queryResultCache)
await this.connection.queryResultCache.synchronize(this.queryRunner);
await this.queryRunner.commitTransaction();
} catch (error) {
@ -86,6 +91,11 @@ export class RdbmsSchemaBuilder implements SchemaBuilder {
this.tableSchemas = await this.loadTableSchemas();
this.queryRunner.enableSqlMemory();
await this.executeSchemaSyncOperationsInProperOrder();
// if cache is enabled then perform cache-synchronization as well
if (this.connection.queryResultCache) // todo: check this functionality
await this.connection.queryResultCache.synchronize(this.queryRunner);
return this.queryRunner.getMemorySql();
} finally {

View File

@ -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;
}

View File

@ -0,0 +1,304 @@
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";
describe("query builder > cache", () => {
let connections: Connection[];
before(async () => connections = await createTestingConnections({
entities: [__dirname + "/entity/*{.js,.ts}"],
dropSchema: true,
// cache: true,
// cache: {
// type: "redis",
// options: {
// host: "localhost",
// }
// }
}));
beforeEach(() => reloadTestingDatabases(connections));
after(() => closeTestingConnections(connections));
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)
.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)
.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)
.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)
.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)
.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)
.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)
.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)
.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);
})));
});

View File

@ -24,7 +24,7 @@ describe("github issues > #521 Attributes in UPDATE in QB arent getting replaced
.where("name = :name", {
name: "Toyota",
})
.getSqlAndParameters();
.getQueryAndParameters();
query.should.not.be.empty;
return parameters.length.should.eql(2);
})));

View File

@ -67,6 +67,39 @@ export interface TestingOptions {
*/
schema?: string;
/**
* Schema name used for postgres driver.
*/
cache?: boolean|{
/**
* Type of caching.
*
* - "database" means cached values will be stored in the separate table in database. This is default value.
* - "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";
/**
* Used to provide mongodb / redis connection options.
*/
options?: any;
/**
* If set to true then queries (using find methods and QueryBuilder's methods) will always be cached.
*/
alwaysEnabled?: boolean;
/**
* Time in milliseconds in which cache will expire.
* This can be setup per-query.
* Default value is 1000 which is equivalent to 1 second.
*/
duration?: number;
};
}
/**
@ -83,6 +116,7 @@ export function setupSingleTestingConnection(driverType: DatabaseType, options:
dropSchema: options.dropSchema ? options.dropSchema : false,
schemaCreate: options.schemaCreate ? options.schemaCreate : false,
enabledDrivers: [driverType],
cache: options.cache,
schema: options.schema ? options.schema : undefined
});
if (!testingConnections.length)
@ -144,6 +178,7 @@ export function setupTestingConnections(options?: TestingOptions): ConnectionOpt
autoSchemaSync: options && (options.entities || options.entitySchemas) ? options.schemaCreate : false,
dropSchema: options && (options.entities || options.entitySchemas) ? options.dropSchema : false,
schema: options && options.schema ? options.schema : undefined,
cache: options ? options.cache : undefined,
});
});
}