mirror of https://github.com/Squidex/squidex.git
Browse Source
* Batch references. * Tests for resolvers. * Easier async code in tests * Tests fixes.pull/806/head
committed by
GitHub
41 changed files with 638 additions and 432 deletions
@ -0,0 +1,209 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { UIOptions } from '@app/framework'; |
||||
|
import { firstValueFrom, of, throwError } from 'rxjs'; |
||||
|
import { IMock, Mock, Times } from 'typemoq'; |
||||
|
import { ContentsDto, ContentsService } from '../services/contents.service'; |
||||
|
import { createContent } from '../services/contents.service.spec'; |
||||
|
import { ResolveContents } from './resolvers'; |
||||
|
import { TestValues } from './_test-helpers'; |
||||
|
|
||||
|
describe('ResolveContents', () => { |
||||
|
const { |
||||
|
app, |
||||
|
appsState, |
||||
|
} = TestValues; |
||||
|
|
||||
|
const uiOptions = new UIOptions({ |
||||
|
referencesDropdownItemCount: 100, |
||||
|
}); |
||||
|
|
||||
|
let contentsService: IMock<ContentsService>; |
||||
|
let contentsResolver: ResolveContents; |
||||
|
|
||||
|
const contents = [ |
||||
|
createContent(1), |
||||
|
createContent(2), |
||||
|
createContent(3), |
||||
|
createContent(4), |
||||
|
]; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
contentsService = Mock.ofType<ContentsService>(); |
||||
|
contentsResolver = new ResolveContents(uiOptions, appsState.object, contentsService.object); |
||||
|
}); |
||||
|
|
||||
|
it('should not resolve contents immediately', () => { |
||||
|
const ids = ['id1', 'id2']; |
||||
|
|
||||
|
contentsService.setup(x => x.getAllContents(app, { ids })) |
||||
|
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); |
||||
|
|
||||
|
return expectAsync(firstValueFrom(contentsResolver.resolveMany(ids))).toBePending(); |
||||
|
}); |
||||
|
|
||||
|
it('should resolve content from one request after delay', async () => { |
||||
|
const ids = ['id1', 'id2']; |
||||
|
|
||||
|
contentsService.setup(x => x.getAllContents(app, { ids })) |
||||
|
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); |
||||
|
|
||||
|
const result = await firstValueFrom(contentsResolver.resolveMany(ids)); |
||||
|
|
||||
|
expect(result.items).toEqual([ |
||||
|
contents[0], |
||||
|
contents[1], |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it('should resolve content if not found', async () => { |
||||
|
const ids = ['id1', 'id2']; |
||||
|
|
||||
|
contentsService.setup(x => x.getAllContents(app, { ids })) |
||||
|
.returns(() => of(new ContentsDto([], 2, [contents[0]]))); |
||||
|
|
||||
|
const result = await firstValueFrom(contentsResolver.resolveMany(ids)); |
||||
|
|
||||
|
expect(result.items).toEqual([ |
||||
|
contents[0], |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it('should resolve errors', () => { |
||||
|
const ids = ['id1', 'id2']; |
||||
|
|
||||
|
contentsService.setup(x => x.getAllContents(app, { ids })) |
||||
|
.returns(() => throwError(() => new Error('error'))); |
||||
|
|
||||
|
return expectAsync(firstValueFrom(contentsResolver.resolveMany(ids))).toBeRejected(); |
||||
|
}); |
||||
|
|
||||
|
it('should batch results', async () => { |
||||
|
const ids1 = ['id1', 'id2']; |
||||
|
const ids2 = ['id2', 'id3']; |
||||
|
|
||||
|
const ids = ['id1', 'id2', 'id3']; |
||||
|
|
||||
|
contentsService.setup(x => x.getAllContents(app, { ids })) |
||||
|
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1], contents[2]]))); |
||||
|
|
||||
|
const result1Promise = firstValueFrom(contentsResolver.resolveMany(ids1)); |
||||
|
const result2Promise = firstValueFrom(contentsResolver.resolveMany(ids2)); |
||||
|
|
||||
|
const [result1, result2] = await Promise.all([result1Promise, result2Promise]); |
||||
|
|
||||
|
expect(result1.items).toEqual([ |
||||
|
contents[0], |
||||
|
contents[1], |
||||
|
]); |
||||
|
|
||||
|
expect(result2.items).toEqual([ |
||||
|
contents[1], |
||||
|
contents[2], |
||||
|
]); |
||||
|
|
||||
|
contentsService.verify(x => x.getAllContents(app, { ids }), Times.once()); |
||||
|
}); |
||||
|
|
||||
|
it('should cache results for parallel requests', async () => { |
||||
|
const ids = ['id1', 'id2']; |
||||
|
|
||||
|
contentsService.setup(x => x.getAllContents(app, { ids })) |
||||
|
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); |
||||
|
|
||||
|
const result1Promise = firstValueFrom(contentsResolver.resolveMany(ids)); |
||||
|
const result2Promise = firstValueFrom(contentsResolver.resolveMany(ids)); |
||||
|
|
||||
|
const [result1, result2] = await Promise.all([result1Promise, result2Promise]); |
||||
|
|
||||
|
expect(result1.items).toEqual([ |
||||
|
contents[0], |
||||
|
contents[1], |
||||
|
]); |
||||
|
|
||||
|
expect(result2.items).toEqual([ |
||||
|
contents[0], |
||||
|
contents[1], |
||||
|
]); |
||||
|
|
||||
|
contentsService.verify(x => x.getAllContents(app, { ids }), Times.once()); |
||||
|
}); |
||||
|
|
||||
|
it('should cache results', async () => { |
||||
|
const ids = ['id1', 'id2']; |
||||
|
|
||||
|
contentsService.setup(x => x.getAllContents(app, { ids })) |
||||
|
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); |
||||
|
|
||||
|
const result1 = await firstValueFrom(contentsResolver.resolveMany(ids)); |
||||
|
const result2 = await firstValueFrom(contentsResolver.resolveMany(ids)); |
||||
|
|
||||
|
expect(result1.items).toEqual([ |
||||
|
contents[0], |
||||
|
contents[1], |
||||
|
]); |
||||
|
|
||||
|
expect(result2.items).toEqual([ |
||||
|
contents[0], |
||||
|
contents[1], |
||||
|
]); |
||||
|
|
||||
|
contentsService.verify(x => x.getAllContents(app, { ids }), Times.once()); |
||||
|
}); |
||||
|
|
||||
|
it('should resolve all contents', async () => { |
||||
|
const schema = 'schema1'; |
||||
|
|
||||
|
contentsService.setup(x => x.getContents(app, schema, { take: 100 })) |
||||
|
.returns(() => of(new ContentsDto([], 2, [contents[0]]))); |
||||
|
|
||||
|
const result = await firstValueFrom(contentsResolver.resolveAll('schema1')); |
||||
|
|
||||
|
expect(result.items).toEqual([ |
||||
|
contents[0], |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it('should cache all contents for parallel requests', async () => { |
||||
|
const schema = 'schema1'; |
||||
|
|
||||
|
contentsService.setup(x => x.getContents(app, schema, { take: 100 })) |
||||
|
.returns(() => of(new ContentsDto([], 2, [contents[0]]))); |
||||
|
|
||||
|
const result1Promise = await firstValueFrom(contentsResolver.resolveAll('schema1')); |
||||
|
const result2Promise = await firstValueFrom(contentsResolver.resolveAll('schema1')); |
||||
|
|
||||
|
const [result1, result2] = await Promise.all([result1Promise, result2Promise]); |
||||
|
|
||||
|
expect(result1.items).toEqual([ |
||||
|
contents[0], |
||||
|
]); |
||||
|
|
||||
|
expect(result2.items).toEqual([ |
||||
|
contents[0], |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it('should cache all contents', async () => { |
||||
|
const schema = 'schema1'; |
||||
|
|
||||
|
contentsService.setup(x => x.getContents(app, schema, { take: 100 })) |
||||
|
.returns(() => of(new ContentsDto([], 2, [contents[0]]))); |
||||
|
|
||||
|
const result1 = await firstValueFrom(contentsResolver.resolveAll('schema1')); |
||||
|
const result2 = await firstValueFrom(contentsResolver.resolveAll('schema1')); |
||||
|
|
||||
|
expect(result1.items).toEqual([ |
||||
|
contents[0], |
||||
|
]); |
||||
|
|
||||
|
expect(result2.items).toEqual([ |
||||
|
contents[0], |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,206 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { Injectable } from '@angular/core'; |
||||
|
import { Observable, from, of, shareReplay } from 'rxjs'; |
||||
|
import { UIOptions } from '@app/framework'; |
||||
|
import { AssetDto, AssetsDto, AssetsService } from './../services/assets.service'; |
||||
|
import { AppsState } from './apps.state'; |
||||
|
import { ContentDto, ContentsDto, ContentsService } from './../services/contents.service'; |
||||
|
|
||||
|
abstract class ResolverBase<T extends { id: string }, TResult extends { items: ReadonlyArray<T> }> { |
||||
|
private readonly items: { [id: string]: Deferred<T | undefined> } = {}; |
||||
|
private pending: { [id: string]: boolean } | null = null; |
||||
|
|
||||
|
public resolveMany(ids: ReadonlyArray<string>): Observable<TResult> { |
||||
|
if (ids.length === 0) { |
||||
|
return of(this.createResult([])); |
||||
|
} |
||||
|
|
||||
|
const nonResolved: string[] = []; |
||||
|
|
||||
|
const promises: Promise<T | undefined>[] = []; |
||||
|
|
||||
|
for (const id of ids) { |
||||
|
let deferred = this.items[id]; |
||||
|
|
||||
|
if (!deferred) { |
||||
|
deferred = new Deferred<T>(); |
||||
|
this.items[id] = deferred; |
||||
|
|
||||
|
nonResolved.push(id); |
||||
|
} |
||||
|
|
||||
|
promises.push(deferred.promise); |
||||
|
} |
||||
|
|
||||
|
if (nonResolved.length > 0) { |
||||
|
if (this.pending === null) { |
||||
|
this.pending = {}; |
||||
|
|
||||
|
setTimeout(() => { |
||||
|
this.resolvePending(); |
||||
|
}, 100); |
||||
|
} |
||||
|
|
||||
|
for (const id of nonResolved) { |
||||
|
this.pending[id] = true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return from(this.buildPromise(promises)); |
||||
|
} |
||||
|
|
||||
|
private async buildPromise(promises: Promise<T | undefined>[]) { |
||||
|
const promise = await Promise.all(promises); |
||||
|
|
||||
|
return this.createResult(promise.filter(x => !!x) as any); |
||||
|
} |
||||
|
|
||||
|
private resolvePending() { |
||||
|
if (!this.pending) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const allIds = Object.keys(this.pending); |
||||
|
|
||||
|
if (allIds.length === 0) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.pending = null; |
||||
|
|
||||
|
for (const ids of chunkArray(allIds, 100)) { |
||||
|
this.resolveIds(ids); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected abstract createResult(items: T[]): TResult; |
||||
|
|
||||
|
protected abstract loadMany(ids: string[]): Observable<TResult>; |
||||
|
|
||||
|
private resolveIds(ids: string[]) { |
||||
|
this.loadMany(ids) |
||||
|
.subscribe({ |
||||
|
next: results => { |
||||
|
for (const id of ids) { |
||||
|
const content = results.items.find(x => x.id === id); |
||||
|
|
||||
|
this.items[id]?.resolve(content); |
||||
|
} |
||||
|
}, |
||||
|
error: ex => { |
||||
|
for (const id of ids) { |
||||
|
this.items[id]?.reject(ex); |
||||
|
} |
||||
|
}, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Injectable() |
||||
|
export class ResolveContents extends ResolverBase<ContentDto, ContentsDto> { |
||||
|
private readonly schemas: { [name: string]: Observable<ContentsDto> } = {}; |
||||
|
private readonly itemCount; |
||||
|
|
||||
|
constructor( |
||||
|
uiOptions: UIOptions, |
||||
|
private readonly appsState: AppsState, |
||||
|
private readonly contentsService: ContentsService, |
||||
|
) { |
||||
|
super(); |
||||
|
|
||||
|
this.itemCount = uiOptions.get('referencesDropdownItemCount'); |
||||
|
} |
||||
|
|
||||
|
public resolveAll(schema: string) { |
||||
|
let result = this.schemas[schema]; |
||||
|
|
||||
|
if (!result) { |
||||
|
result = this.contentsService.getContents(this.appName, schema, { take: this.itemCount }).pipe(shareReplay(1)); |
||||
|
|
||||
|
this.schemas[schema] = result; |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
protected createResult(items: ContentDto[]) { |
||||
|
return new ContentsDto([], items.length, items); |
||||
|
} |
||||
|
|
||||
|
protected loadMany(ids: string[]) { |
||||
|
return this.contentsService.getAllContents(this.appName, { ids }); |
||||
|
} |
||||
|
|
||||
|
private get appName() { |
||||
|
return this.appsState.appName; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Injectable() |
||||
|
export class ResolveAssets extends ResolverBase<AssetDto, AssetsDto> { |
||||
|
constructor( |
||||
|
private readonly appsState: AppsState, |
||||
|
private readonly assetsService: AssetsService, |
||||
|
) { |
||||
|
super(); |
||||
|
} |
||||
|
|
||||
|
protected createResult(items: AssetDto[]) { |
||||
|
return new AssetsDto(items.length, items); |
||||
|
} |
||||
|
|
||||
|
protected loadMany(ids: string[]) { |
||||
|
return this.assetsService.getAssets(this.appName, { ids }); |
||||
|
} |
||||
|
|
||||
|
private get appName() { |
||||
|
return this.appsState.appName; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function chunkArray<T>(array: T[], size: number): T[][] { |
||||
|
if (array.length > size) { |
||||
|
return [array.slice(0, size), ...chunkArray(array.slice(size), size)]; |
||||
|
} else { |
||||
|
return [array]; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class Deferred<T> { |
||||
|
private handleResolve: Function; |
||||
|
private handleReject: Function; |
||||
|
private isHandled = false; |
||||
|
|
||||
|
public readonly promise: Promise<T>; |
||||
|
|
||||
|
constructor() { |
||||
|
this.promise = new Promise<T>((resolve, reject) => { |
||||
|
this.handleResolve = resolve; |
||||
|
this.handleReject = reject; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public resolve(value: T | PromiseLike<T>) { |
||||
|
if (this.isHandled) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.isHandled = true; |
||||
|
this.handleResolve(value); |
||||
|
} |
||||
|
|
||||
|
public reject(reason?: any) { |
||||
|
if (this.isHandled) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.isHandled = true; |
||||
|
this.handleReject(reason); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue