mirror of https://github.com/Squidex/squidex.git
committed by
GitHub
77 changed files with 2946 additions and 1751 deletions
@ -0,0 +1,67 @@ |
|||||
|
<div class="row"> |
||||
|
<div class="col"> |
||||
|
<div class="form-inline"> |
||||
|
<select class="form-control path mb-1 mr-2" [ngModel]="filter.path" (ngModelChange)="changePath($event)"> |
||||
|
<option *ngFor="let fieldName of model.fields | sqxKeys" [ngValue]="fieldName">{{fieldName}}</option> |
||||
|
</select> |
||||
|
|
||||
|
<ng-container *ngIf="fieldModel"> |
||||
|
<select class="form-control mb-1 mr-2" [ngModel]="filter.op" (ngModelChange)="changeOp($event)"> |
||||
|
<option *ngFor="let operator of fieldModel.operators" [ngValue]="operator.value">{{operator.name || operator.value}}</option> |
||||
|
</select> |
||||
|
|
||||
|
<div class="mb-1" *ngIf="!noValue" [ngSwitch]="fieldModel.type"> |
||||
|
<ng-container *ngSwitchCase="'boolean'"> |
||||
|
<input type="checkbox" class="form-control" |
||||
|
[ngModel]="filter.value" |
||||
|
(ngModelChange)="changeValue($event)" /> |
||||
|
</ng-container> |
||||
|
<ng-container *ngSwitchCase="'date'"> |
||||
|
<sqx-date-time-editor mode="Date" |
||||
|
[ngModel]="filter.value" |
||||
|
(ngModelChange)="changeValue($event)"> |
||||
|
</sqx-date-time-editor> |
||||
|
</ng-container> |
||||
|
<ng-container *ngSwitchCase="'datetime'"> |
||||
|
<sqx-date-time-editor mode="DateTime" |
||||
|
[ngModel]="filter.value" |
||||
|
(ngModelChange)="changeValue($event)"> |
||||
|
</sqx-date-time-editor> |
||||
|
</ng-container> |
||||
|
<ng-container *ngSwitchCase="'number'"> |
||||
|
<input type="number" class="form-control" |
||||
|
[ngModel]="filter.value" |
||||
|
(ngModelChange)="changeValue($event)" /> |
||||
|
</ng-container> |
||||
|
<ng-container *ngSwitchCase="'reference'"> |
||||
|
<sqx-references-dropdown [schemaId]="fieldModel.extra" |
||||
|
mode="Single" |
||||
|
[ngModel]="filter.value" |
||||
|
(ngModelChange)="changeValue($event)"> |
||||
|
</sqx-references-dropdown> |
||||
|
</ng-container> |
||||
|
<ng-container *ngSwitchCase="'status'"> |
||||
|
<sqx-dropdown [items]="fieldModel.extra" |
||||
|
[ngModel]="getStatus(fieldModel.extra)" |
||||
|
(ngModelChange)="changeStatus($event)" |
||||
|
[canSearch]="false"> |
||||
|
<ng-template let-target="$implicit"> |
||||
|
<i class="icon-circle" [style.color]="target.color"></i> {{target.status}} |
||||
|
</ng-template> |
||||
|
</sqx-dropdown> |
||||
|
</ng-container> |
||||
|
<ng-container *ngSwitchCase="'string'"> |
||||
|
<input type="text" class="form-control" *ngIf="!fieldModel.extra" |
||||
|
[ngModel]="filter.value" |
||||
|
(ngModelChange)="changeValue($event)" /> |
||||
|
</ng-container> |
||||
|
</div> |
||||
|
</ng-container> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-auto pl-2"> |
||||
|
<button type="button" class="btn btn-text-danger" (click)="remove.emit()"> |
||||
|
<i class="icon-bin2"></i> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,6 @@ |
|||||
|
@import '_vars'; |
||||
|
@import '_mixins'; |
||||
|
|
||||
|
.path { |
||||
|
max-width: 12rem; |
||||
|
} |
||||
@ -0,0 +1,106 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; |
||||
|
|
||||
|
import { |
||||
|
fadeAnimation, |
||||
|
FilterComparison, |
||||
|
QueryFieldModel, |
||||
|
QueryModel |
||||
|
} from '@app/shared/internal'; |
||||
|
import { StatusInfo } from '@app/shared/services/contents.service'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'sqx-filter-comparison', |
||||
|
styleUrls: ['./filter-comparison.component.scss'], |
||||
|
templateUrl: './filter-comparison.component.html', |
||||
|
animations: [ |
||||
|
fadeAnimation |
||||
|
], |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush |
||||
|
}) |
||||
|
export class FilterComparisonComponent implements OnChanges { |
||||
|
public fieldModel: QueryFieldModel; |
||||
|
|
||||
|
public noValue = false; |
||||
|
|
||||
|
@Output() |
||||
|
public change = new EventEmitter(); |
||||
|
|
||||
|
@Output() |
||||
|
public remove = new EventEmitter(); |
||||
|
|
||||
|
@Input() |
||||
|
public model: QueryModel; |
||||
|
|
||||
|
@Input() |
||||
|
public filter: FilterComparison; |
||||
|
|
||||
|
public ngOnChanges(changes: SimpleChanges) { |
||||
|
if (changes['filter']) { |
||||
|
this.updatePath(false); |
||||
|
this.updateOperator(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public getStatus(statuses: StatusInfo[]) { |
||||
|
return statuses.find(x => x.status === this.filter.value); |
||||
|
} |
||||
|
|
||||
|
public changeStatus(status: StatusInfo) { |
||||
|
this.changeValue(status.status); |
||||
|
} |
||||
|
|
||||
|
public changeValue(value: any) { |
||||
|
this.filter.value = value; |
||||
|
|
||||
|
this.emitChange(); |
||||
|
} |
||||
|
|
||||
|
public changeOp(op: string) { |
||||
|
this.filter.op = op; |
||||
|
|
||||
|
this.updateOperator(); |
||||
|
|
||||
|
this.emitChange(); |
||||
|
} |
||||
|
|
||||
|
public changePath(path: string) { |
||||
|
this.filter.path = path; |
||||
|
|
||||
|
this.updatePath(true); |
||||
|
|
||||
|
this.emitChange(); |
||||
|
} |
||||
|
|
||||
|
private updateOperator() { |
||||
|
if (this.fieldModel) { |
||||
|
const operator = this.fieldModel.operators.find(x => x.value === this.filter.op); |
||||
|
|
||||
|
this.noValue = !!(operator && operator.noValue); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private updatePath(refresh: boolean) { |
||||
|
const newModel = this.model.fields[this.filter.path]; |
||||
|
|
||||
|
if (newModel && refresh) { |
||||
|
if (!newModel.operators.find(x => x.value === this.filter.op)) { |
||||
|
this.filter.op = newModel.operators[0].value; |
||||
|
} |
||||
|
|
||||
|
this.filter.value = null; |
||||
|
} |
||||
|
|
||||
|
this.fieldModel = newModel; |
||||
|
} |
||||
|
|
||||
|
private emitChange() { |
||||
|
this.change.emit(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,45 @@ |
|||||
|
<div class="group"> |
||||
|
<div class="row no-gutters"> |
||||
|
<div class="col"> |
||||
|
<div class="btn-group"> |
||||
|
<button type="button" class="btn btn-secondary btn-toggle" [class.btn-primary]="filter.and" [disabled]="filter.and" (click)="toggleType()"> |
||||
|
AND |
||||
|
</button> |
||||
|
<button type="button" class="btn btn-secondary btn-toggle" [class.btn-primary]="filter.or" [disabled]="filter.or" (click)="toggleType()"> |
||||
|
OR |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
|
||||
|
<div class="col-auto pl-2" *ngIf="!isRoot"> |
||||
|
<button type="button" class="btn btn-text-danger" (click)="remove.emit()"> |
||||
|
<i class="icon-bin2"></i> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="filters"> |
||||
|
<span class="filter-line-v"></span> |
||||
|
|
||||
|
<div class="filter mt-3" *ngFor="let filter of filters"> |
||||
|
<span class="filter-line-h"></span> |
||||
|
|
||||
|
<sqx-filter-node [filter]="filter" [model]="model" [level]="level + 1" |
||||
|
(remove)="removeFilter(filter)" (change)="change.emit()"> |
||||
|
</sqx-filter-node> |
||||
|
</div> |
||||
|
|
||||
|
<div class="filter filter-add mt-3"> |
||||
|
<span class="filter-line-h"></span> |
||||
|
|
||||
|
<button class="btn btn-outline-success btn-sm mr-2" (click)="addComparison()"> |
||||
|
Add Filter |
||||
|
</button> |
||||
|
|
||||
|
<button class="btn btn-outline-success btn-sm" (click)="addLogical()" *ngIf="level < 1"> |
||||
|
Add Group |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,35 @@ |
|||||
|
@import '_vars'; |
||||
|
@import '_mixins'; |
||||
|
|
||||
|
$field-line: #e1e8ef; |
||||
|
|
||||
|
.filters { |
||||
|
position: relative; |
||||
|
padding-left: 3rem; |
||||
|
} |
||||
|
|
||||
|
.filter { |
||||
|
& { |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
&-line-v { |
||||
|
@include absolute(-.5rem, auto, 1.8rem, 1.5rem); |
||||
|
border: 0; |
||||
|
border-left: 2px dashed $field-line; |
||||
|
width: 2px; |
||||
|
} |
||||
|
|
||||
|
&-line-h { |
||||
|
@include absolute(1.2rem, auto, auto, -1.5rem); |
||||
|
border: 0; |
||||
|
border-bottom: 2px dashed $field-line; |
||||
|
width: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
&-add { |
||||
|
background: none; |
||||
|
border: 2px dashed $field-line; |
||||
|
padding: .5rem; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,100 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; |
||||
|
|
||||
|
import { |
||||
|
fadeAnimation, |
||||
|
FilterLogical, |
||||
|
FilterNode, |
||||
|
QueryModel |
||||
|
} from '@app/shared/internal'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'sqx-filter-logical', |
||||
|
styleUrls: ['./filter-logical.component.scss'], |
||||
|
templateUrl: './filter-logical.component.html', |
||||
|
animations: [ |
||||
|
fadeAnimation |
||||
|
], |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush |
||||
|
}) |
||||
|
export class FilterLogicalComponent { |
||||
|
private filterValue: FilterLogical; |
||||
|
|
||||
|
public filters: FilterNode[] = []; |
||||
|
|
||||
|
@Output() |
||||
|
public change = new EventEmitter(); |
||||
|
|
||||
|
@Output() |
||||
|
public remove = new EventEmitter(); |
||||
|
|
||||
|
@Input() |
||||
|
public level = 0; |
||||
|
|
||||
|
@Input() |
||||
|
public isRoot = false; |
||||
|
|
||||
|
@Input() |
||||
|
public model: QueryModel; |
||||
|
|
||||
|
@Input() |
||||
|
public set filter(filter: FilterLogical) { |
||||
|
this.filterValue = filter; |
||||
|
|
||||
|
this.updateFilters(filter); |
||||
|
} |
||||
|
|
||||
|
public get filter() { |
||||
|
return this.filterValue; |
||||
|
} |
||||
|
|
||||
|
public addComparison() { |
||||
|
this.filters.push(<any>{ path: Object.keys(this.model.fields)[0] }); |
||||
|
|
||||
|
this.emitChange(); |
||||
|
} |
||||
|
|
||||
|
public addLogical() { |
||||
|
this.filters.push({ and: [] }); |
||||
|
|
||||
|
this.emitChange(); |
||||
|
} |
||||
|
|
||||
|
public removeFilter(node: FilterNode) { |
||||
|
this.filters.splice(this.filters.indexOf(node), 1); |
||||
|
|
||||
|
this.emitChange(); |
||||
|
} |
||||
|
|
||||
|
public toggleType() { |
||||
|
if (this.filterValue.and) { |
||||
|
this.filterValue.or = this.filterValue.and; |
||||
|
|
||||
|
delete this.filterValue.and; |
||||
|
} else { |
||||
|
this.filterValue.and = this.filterValue.or; |
||||
|
|
||||
|
delete this.filterValue.or; |
||||
|
} |
||||
|
|
||||
|
this.emitChange(); |
||||
|
} |
||||
|
|
||||
|
private updateFilters(filter: FilterLogical) { |
||||
|
if (filter) { |
||||
|
this.filters = filter.and || filter.or || []; |
||||
|
} else { |
||||
|
this.filters = []; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private emitChange() { |
||||
|
this.change.emit(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,59 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; |
||||
|
|
||||
|
import { |
||||
|
FilterComparison, |
||||
|
FilterLogical, |
||||
|
FilterNode, |
||||
|
QueryModel |
||||
|
} from '@app/shared/internal'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'sqx-filter-node', |
||||
|
template: ` |
||||
|
<ng-container *ngIf="logical"> |
||||
|
<sqx-filter-logical [model]="model" [filter]="logical" [level]="level" |
||||
|
(remove)="remove.emit()" (change)="change.emit()"> |
||||
|
</sqx-filter-logical> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container *ngIf="comparison"> |
||||
|
<sqx-filter-comparison [model]="model" [filter]="comparison" |
||||
|
(remove)="remove.emit()" (change)="change.emit()"> |
||||
|
</sqx-filter-comparison> |
||||
|
</ng-container> |
||||
|
`,
|
||||
|
changeDetection: ChangeDetectionStrategy.OnPush |
||||
|
}) |
||||
|
export class FilterNodeComponent { |
||||
|
public comparison: FilterComparison; |
||||
|
|
||||
|
public logical: FilterLogical; |
||||
|
|
||||
|
@Output() |
||||
|
public change = new EventEmitter(); |
||||
|
|
||||
|
@Output() |
||||
|
public remove = new EventEmitter(); |
||||
|
|
||||
|
@Input() |
||||
|
public level: number; |
||||
|
|
||||
|
@Input() |
||||
|
public model: QueryModel; |
||||
|
|
||||
|
@Input() |
||||
|
public set filter(value: FilterNode) { |
||||
|
if (value['and'] || value['or']) { |
||||
|
this.logical = <FilterLogical>value; |
||||
|
} else { |
||||
|
this.comparison = <FilterComparison>value; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,83 @@ |
|||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; |
||||
|
|
||||
|
import { |
||||
|
Query, |
||||
|
QueryModel, |
||||
|
QuerySorting |
||||
|
} from '@app/shared/internal'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'sqx-query', |
||||
|
template: ` |
||||
|
<div> |
||||
|
<h4>Filter</h4> |
||||
|
|
||||
|
<sqx-filter-logical isRoot="true" [filter]="queryValue.filter" [model]="model" |
||||
|
(change)="emitQueryChange()"> |
||||
|
</sqx-filter-logical> |
||||
|
|
||||
|
<h4 class="mt-4">Sorting</h4> |
||||
|
|
||||
|
<div class="mb-2" *ngFor="let sorting of queryValue.sort"> |
||||
|
<sqx-sorting [sorting]="sorting" [model]="model" |
||||
|
(remove)="removeSorting(sorting)" (change)="emitQueryChange()"> |
||||
|
</sqx-sorting> |
||||
|
</div> |
||||
|
|
||||
|
<button class="btn btn-outline-success btn-sm mr-2" (click)="addSorting()"> |
||||
|
Add Sorting |
||||
|
</button> |
||||
|
</div> |
||||
|
`,
|
||||
|
changeDetection: ChangeDetectionStrategy.OnPush |
||||
|
}) |
||||
|
export class QueryComponent { |
||||
|
public queryValue: Query; |
||||
|
|
||||
|
@Output() |
||||
|
public queryChange = new EventEmitter<Query>(); |
||||
|
|
||||
|
@Input() |
||||
|
public model: QueryModel; |
||||
|
|
||||
|
@Input() |
||||
|
public set query(query: Query) { |
||||
|
if (!query) { |
||||
|
query = {}; |
||||
|
} |
||||
|
|
||||
|
if (query) { |
||||
|
if (!query.filter) { |
||||
|
query.filter = { |
||||
|
and: [] |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
if (!query.sort) { |
||||
|
query.sort = []; |
||||
|
} |
||||
|
|
||||
|
this.queryValue = query; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
constructor() { |
||||
|
this.query = {}; |
||||
|
} |
||||
|
|
||||
|
public addSorting() { |
||||
|
this.queryValue.sort!.push({ path: Object.keys(this.model.fields)[0], order: 'ascending' }); |
||||
|
|
||||
|
this.emitQueryChange(); |
||||
|
} |
||||
|
|
||||
|
public removeSorting(sorting: QuerySorting) { |
||||
|
this.queryValue.sort!.splice(this.queryValue.sort!.indexOf(sorting), 1); |
||||
|
|
||||
|
this.emitQueryChange(); |
||||
|
} |
||||
|
|
||||
|
public emitQueryChange() { |
||||
|
this.queryChange.emit(this.queryValue); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,65 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; |
||||
|
|
||||
|
import { QueryModel, QuerySorting } from '@app/shared/internal'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'sqx-sorting', |
||||
|
template: ` |
||||
|
<div class="row"> |
||||
|
<div class="col"> |
||||
|
<div class="form-inline"> |
||||
|
<select class="form-control mr-2" [ngModel]="sorting.path" (ngModelChange)="changePath($event)"> |
||||
|
<option *ngFor="let fieldName of model.fields | sqxKeys" [ngValue]="fieldName">{{fieldName}}</option> |
||||
|
</select> |
||||
|
|
||||
|
<select class="form-control mr-2" [ngModel]="sorting.order" (ngModelChange)="changeOrder($event)"> |
||||
|
<option>ascending</option> |
||||
|
<option>descending</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-auto pl-2"> |
||||
|
<button type="button" class="btn btn-text-danger" (click)="remove.emit()"> |
||||
|
<i class="icon-bin2"></i> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
`,
|
||||
|
changeDetection: ChangeDetectionStrategy.OnPush |
||||
|
}) |
||||
|
export class SortingComponent { |
||||
|
@Output() |
||||
|
public change = new EventEmitter(); |
||||
|
|
||||
|
@Output() |
||||
|
public remove = new EventEmitter(); |
||||
|
|
||||
|
@Input() |
||||
|
public model: QueryModel; |
||||
|
|
||||
|
@Input() |
||||
|
public sorting: QuerySorting; |
||||
|
|
||||
|
public changeOrder(order: any) { |
||||
|
this.sorting.order = order; |
||||
|
|
||||
|
this.emitChange(); |
||||
|
} |
||||
|
|
||||
|
public changePath(path: string) { |
||||
|
this.sorting.path = path; |
||||
|
|
||||
|
this.emitChange(); |
||||
|
} |
||||
|
|
||||
|
private emitChange() { |
||||
|
this.change.emit(); |
||||
|
} |
||||
|
} |
||||
@ -1,150 +0,0 @@ |
|||||
/* |
|
||||
* Squidex Headless CMS |
|
||||
* |
|
||||
* @license |
|
||||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|
||||
*/ |
|
||||
|
|
||||
import { |
|
||||
FilterState, |
|
||||
LanguageDto, |
|
||||
Sorting |
|
||||
} from '@app/shared/internal'; |
|
||||
|
|
||||
describe('FilterState', () => { |
|
||||
let query: string | undefined; |
|
||||
let fullText: string | undefined; |
|
||||
let filterState: FilterState; |
|
||||
let filter: string | undefined; |
|
||||
let order: string | undefined; |
|
||||
|
|
||||
beforeEach(() => { |
|
||||
filterState = new FilterState(); |
|
||||
filterState.setLanguage(new LanguageDto('de', 'German')); |
|
||||
|
|
||||
filterState.query.subscribe(value => { |
|
||||
query = value; |
|
||||
}); |
|
||||
|
|
||||
filterState.filter.subscribe(value => { |
|
||||
filter = value; |
|
||||
}); |
|
||||
|
|
||||
filterState.fullText.subscribe(value => { |
|
||||
fullText = value; |
|
||||
}); |
|
||||
|
|
||||
filterState.order.subscribe(value => { |
|
||||
order = value; |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
it('should parse elements from query', () => { |
|
||||
const newQuery = '$filter=MY_FILTER&$orderby=MY_FIELD desc&$search=MY_TEXT'; |
|
||||
|
|
||||
filterState.setQuery(newQuery); |
|
||||
|
|
||||
expect(order).toBe('MY_FIELD desc'); |
|
||||
expect(query).toBe(newQuery); |
|
||||
expect(filter).toBe('MY_FILTER'); |
|
||||
expect(fullText).toBe('MY_TEXT'); |
|
||||
}); |
|
||||
|
|
||||
it('should set full text and order and calculate query', () => { |
|
||||
filterState.setFullText('Hello World'); |
|
||||
filterState.setOrder('data/name/iv asc'); |
|
||||
|
|
||||
expect(query).toBe('$search=Hello World&$orderby=data/name/iv asc'); |
|
||||
expect(fullText).toBe('Hello World'); |
|
||||
}); |
|
||||
|
|
||||
it('should set full text only and calculate query', () => { |
|
||||
filterState.setFullText('Hello World'); |
|
||||
|
|
||||
expect(query).toBe('Hello World'); |
|
||||
expect(fullText).toBe('Hello World'); |
|
||||
}); |
|
||||
|
|
||||
it('should set filter and calculate query', () => { |
|
||||
filterState.setFilter('data/name/iv eq "Squidex"'); |
|
||||
|
|
||||
expect(query).toBe('$filter=data/name/iv eq "Squidex"'); |
|
||||
expect(filter).toBe('data/name/iv eq "Squidex"'); |
|
||||
}); |
|
||||
|
|
||||
it('should set order and calculate query', () => { |
|
||||
filterState.setOrder('data/name/iv asc'); |
|
||||
|
|
||||
expect(query).toBe('$orderby=data/name/iv asc'); |
|
||||
expect(order).toBe('data/name/iv asc'); |
|
||||
}); |
|
||||
|
|
||||
it('should set field name and calculate query', () => { |
|
||||
filterState.setOrderField('field', 'Descending'); |
|
||||
|
|
||||
expect(query).toBe('$orderby=field desc'); |
|
||||
expect(order).toBe('field desc'); |
|
||||
}); |
|
||||
|
|
||||
it('should set field and calculate query', () => { |
|
||||
filterState.setOrderField(<any>{ name: 'first-name', isLocalizable: false }, 'Ascending'); |
|
||||
|
|
||||
expect(query).toBe('$orderby=data/first_name/iv asc'); |
|
||||
expect(order).toBe('data/first_name/iv asc'); |
|
||||
}); |
|
||||
|
|
||||
it('should set localizable field and calculate query', () => { |
|
||||
filterState.setOrderField(<any>{ name: 'first-name', isLocalizable: true }, 'Ascending'); |
|
||||
|
|
||||
expect(query).toBe('$orderby=data/first_name/de asc'); |
|
||||
expect(order).toBe('data/first_name/de asc'); |
|
||||
}); |
|
||||
|
|
||||
it('should update field ordering for path', () => { |
|
||||
let sorting: Sorting; |
|
||||
|
|
||||
filterState.sortMode('field').subscribe(value => { |
|
||||
sorting = value; |
|
||||
}); |
|
||||
|
|
||||
filterState.setQuery('$orderby=field desc'); |
|
||||
|
|
||||
expect(sorting!).toBe('Descending'); |
|
||||
}); |
|
||||
|
|
||||
it('should update field ordering for field', () => { |
|
||||
let sorting: Sorting; |
|
||||
|
|
||||
filterState.sortMode(<any>{ name: 'first-name', fieldId: 1, isLocalizable: false }).subscribe(value => { |
|
||||
sorting = value; |
|
||||
}); |
|
||||
|
|
||||
filterState.setQuery('$orderby=data/first_name/iv asc'); |
|
||||
|
|
||||
expect(sorting!).toBe('Ascending'); |
|
||||
}); |
|
||||
|
|
||||
it('should update field ordering for localizable field', () => { |
|
||||
let sorting: Sorting; |
|
||||
|
|
||||
filterState.sortMode(<any>{ name: 'first-name', fieldId: 1, isLocalizable: true }).subscribe(value => { |
|
||||
sorting = value; |
|
||||
}); |
|
||||
|
|
||||
filterState.setQuery('$orderby=data/first_name/de asc'); |
|
||||
|
|
||||
expect(sorting!).toBe('Ascending'); |
|
||||
}); |
|
||||
|
|
||||
it('should update field ordering for localizable field and other language', () => { |
|
||||
let sorting: Sorting; |
|
||||
|
|
||||
filterState.sortMode(<any>{ name: 'first-name', fieldId: 1, isLocalizable: true }).subscribe(value => { |
|
||||
sorting = value; |
|
||||
}); |
|
||||
|
|
||||
filterState.setQuery('$orderby=data/first_name/en asc'); |
|
||||
|
|
||||
expect(sorting!).toBe('None'); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,215 +0,0 @@ |
|||||
/* |
|
||||
* Squidex Headless CMS |
|
||||
* |
|
||||
* @license |
|
||||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|
||||
*/ |
|
||||
|
|
||||
import { Observable } from 'rxjs'; |
|
||||
import { distinctUntilChanged } from 'rxjs/internal/operators'; |
|
||||
|
|
||||
import { State, Types } from '@app/framework'; |
|
||||
|
|
||||
import { LanguageDto } from './../services/languages.service'; |
|
||||
|
|
||||
import { RootFieldDto } from './../services/schemas.service'; |
|
||||
|
|
||||
interface Snapshot { |
|
||||
// The current language.
|
|
||||
language?: LanguageDto; |
|
||||
|
|
||||
// The order statement.
|
|
||||
order?: string; |
|
||||
|
|
||||
// The order field.
|
|
||||
orderField?: string; |
|
||||
|
|
||||
// The order direction.
|
|
||||
orderDirection?: string; |
|
||||
|
|
||||
// The final query.
|
|
||||
query?: string; |
|
||||
|
|
||||
// The odata filter statement.
|
|
||||
filter?: string; |
|
||||
|
|
||||
// The odata full text statement.
|
|
||||
fullText?: string; |
|
||||
} |
|
||||
|
|
||||
export type Sorting = 'Ascending' | 'Descending' | 'None'; |
|
||||
|
|
||||
type Field = string | RootFieldDto; |
|
||||
|
|
||||
export class FilterState extends State<Snapshot> { |
|
||||
private readonly sortModes: { [key: string]: Observable<Sorting> } = {}; |
|
||||
|
|
||||
public query = |
|
||||
this.project(x => x.query); |
|
||||
|
|
||||
public order = |
|
||||
this.project(x => x.order); |
|
||||
|
|
||||
public filter = |
|
||||
this.project(x => x.filter); |
|
||||
|
|
||||
public fullText = |
|
||||
this.project(x => x.fullText); |
|
||||
|
|
||||
public get apiFilter() { |
|
||||
return this.snapshot.query; |
|
||||
} |
|
||||
|
|
||||
public constructor() { |
|
||||
super({}); |
|
||||
} |
|
||||
|
|
||||
public sortMode(field: Field) { |
|
||||
const key = Types.isString(field) ? field : field.fieldId.toString(); |
|
||||
|
|
||||
let result = this.sortModes[key]; |
|
||||
|
|
||||
if (!result) { |
|
||||
result = this.project(x => sortMode(x, field)), distinctUntilChanged(); |
|
||||
|
|
||||
this.sortModes[key] = result; |
|
||||
} |
|
||||
|
|
||||
return result; |
|
||||
} |
|
||||
|
|
||||
public setQuery(query?: string) { |
|
||||
this.next(s => fromQuery(s, query)); |
|
||||
} |
|
||||
|
|
||||
public setOrder(order?: string) { |
|
||||
this.next(s => fromProperty(s, { order })); |
|
||||
} |
|
||||
|
|
||||
public setFilter(filter?: string) { |
|
||||
this.next(s => fromProperty(s, { filter })); |
|
||||
} |
|
||||
|
|
||||
public setFullText(fullText?: string) { |
|
||||
this.next(s => fromProperty(s, { fullText })); |
|
||||
} |
|
||||
|
|
||||
public setLanguage(language: LanguageDto) { |
|
||||
this.next(s => ({ ...s, language })); |
|
||||
} |
|
||||
|
|
||||
public setOrderField(field: Field, sorting: Sorting) { |
|
||||
this.setOrder(getFieldSorting(this.snapshot, field, sorting)); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
function sortMode(snapshot: Snapshot, field: Field): Sorting { |
|
||||
let path = getFieldPath(snapshot, field); |
|
||||
|
|
||||
if (snapshot.orderField === path) { |
|
||||
if (snapshot.orderDirection === 'asc') { |
|
||||
return 'Ascending'; |
|
||||
} else if (snapshot.orderDirection === 'desc') { |
|
||||
return 'Descending'; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return 'None'; |
|
||||
} |
|
||||
|
|
||||
function escapeField(value: string) { |
|
||||
return value.replace('-', '_'); |
|
||||
} |
|
||||
|
|
||||
function getFieldSorting(snapshot: Snapshot, field: Field, sorting: Sorting) { |
|
||||
if (sorting === 'Ascending') { |
|
||||
return `${getFieldPath(snapshot, field)} asc`; |
|
||||
} else { |
|
||||
return `${getFieldPath(snapshot, field)} desc`; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
function getFieldPath(snapshot: Snapshot, field?: Field) { |
|
||||
let path: string | undefined = undefined; |
|
||||
|
|
||||
if (field) { |
|
||||
if (Types.isString(field)) { |
|
||||
path = field; |
|
||||
} else if (field.isLocalizable && snapshot.language) { |
|
||||
path = `data/${escapeField(field.name)}/${snapshot.language.iso2Code.replace('-', '_')}`; |
|
||||
} else { |
|
||||
path = `data/${escapeField(field.name)}/iv`; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return path; |
|
||||
} |
|
||||
|
|
||||
function fromQuery(previous: Snapshot, query?: string) { |
|
||||
const snapshot: Snapshot = { language: previous.language, query }; |
|
||||
|
|
||||
if (query) { |
|
||||
const parts = query.split('&'); |
|
||||
|
|
||||
if (parts.length === 1 && parts[0][0] !== '$') { |
|
||||
snapshot.fullText = parts[0]; |
|
||||
} else { |
|
||||
for (let part of parts) { |
|
||||
const kvp = part.split('='); |
|
||||
|
|
||||
if (kvp.length === 2) { |
|
||||
const key = kvp[0].toLowerCase(); |
|
||||
|
|
||||
if (key === '$filter') { |
|
||||
snapshot.filter = kvp[1]; |
|
||||
} else if (key === '$orderby') { |
|
||||
snapshot.order = kvp[1]; |
|
||||
} else if (key === '$search') { |
|
||||
snapshot.fullText = kvp[1]; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return enrichOrderField(snapshot); |
|
||||
} |
|
||||
|
|
||||
function fromProperty(previous: Snapshot, update: Partial<Snapshot>) { |
|
||||
const snapshot = { ...previous, ...update }; |
|
||||
|
|
||||
if (snapshot.fullText && !snapshot.order && !snapshot.filter) { |
|
||||
snapshot.query = snapshot.fullText; |
|
||||
} else { |
|
||||
const parts: string[] = []; |
|
||||
|
|
||||
if (snapshot.fullText) { |
|
||||
parts.push(`$search=${snapshot.fullText}`); |
|
||||
} |
|
||||
|
|
||||
if (snapshot.filter) { |
|
||||
parts.push(`$filter=${snapshot.filter}`); |
|
||||
} |
|
||||
|
|
||||
if (snapshot.order) { |
|
||||
parts.push(`$orderby=${snapshot.order}`); |
|
||||
} |
|
||||
|
|
||||
snapshot.query = parts.join('&'); |
|
||||
} |
|
||||
|
|
||||
return enrichOrderField(snapshot); |
|
||||
} |
|
||||
|
|
||||
function enrichOrderField(snapshot: Snapshot) { |
|
||||
if (snapshot.order) { |
|
||||
const orderParts = snapshot.order.split(' '); |
|
||||
|
|
||||
if (orderParts.length === 2) { |
|
||||
snapshot.orderField = orderParts[0]; |
|
||||
snapshot.orderDirection = orderParts[1]; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return snapshot; |
|
||||
} |
|
||||
@ -0,0 +1,218 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { Types } from '@app/framework'; |
||||
|
|
||||
|
import { StatusInfo } from './../services/contents.service'; |
||||
|
import { LanguageDto } from './../services/languages.service'; |
||||
|
import { SchemaDetailsDto } from './../services/schemas.service'; |
||||
|
|
||||
|
export type QueryValueType = |
||||
|
'boolean' | |
||||
|
'date' | |
||||
|
'datetime' | |
||||
|
'number' | |
||||
|
'reference' | |
||||
|
'status' | |
||||
|
'string'; |
||||
|
|
||||
|
export interface FilterOperator { |
||||
|
// The optional display value.
|
||||
|
name?: string; |
||||
|
|
||||
|
// The operator value.
|
||||
|
value: string; |
||||
|
|
||||
|
// True, when the operator does not require an value.
|
||||
|
noValue?: boolean; |
||||
|
} |
||||
|
|
||||
|
export interface QueryFieldModel { |
||||
|
// The value type.
|
||||
|
type: QueryValueType; |
||||
|
|
||||
|
// The allowed operator.
|
||||
|
operators: FilterOperator[]; |
||||
|
|
||||
|
// Extra values.
|
||||
|
extra?: any; |
||||
|
} |
||||
|
|
||||
|
export interface QueryModel { |
||||
|
// All available fields.
|
||||
|
fields: { [name: string]: QueryFieldModel }; |
||||
|
} |
||||
|
|
||||
|
export type FilterNode = FilterComparison | FilterLogical; |
||||
|
|
||||
|
export interface FilterComparison { |
||||
|
// The full path to the property.
|
||||
|
path: string; |
||||
|
|
||||
|
// The operator.
|
||||
|
op: string; |
||||
|
|
||||
|
// The value.
|
||||
|
value: any; |
||||
|
} |
||||
|
|
||||
|
export interface FilterLogical { |
||||
|
// The child filters if the logical filter is a conjunction (AND).
|
||||
|
and?: FilterNode[]; |
||||
|
|
||||
|
// The child filters if the logical filter is a disjunction (OR).
|
||||
|
or?: FilterNode[]; |
||||
|
} |
||||
|
|
||||
|
export interface QuerySorting { |
||||
|
// The full path to the property.
|
||||
|
path: string; |
||||
|
|
||||
|
// The sort order.
|
||||
|
order: SortMode; |
||||
|
} |
||||
|
|
||||
|
export type SortMode = 'ascending' | 'descending'; |
||||
|
|
||||
|
export interface Query { |
||||
|
// The optional filter.
|
||||
|
filter?: FilterNode; |
||||
|
|
||||
|
// The full text search.
|
||||
|
fullText?: string; |
||||
|
|
||||
|
// The sorting.
|
||||
|
sort?: QuerySorting[]; |
||||
|
|
||||
|
// The number of items to take.
|
||||
|
take?: number; |
||||
|
|
||||
|
// The number of items to skip.
|
||||
|
skip?: number; |
||||
|
} |
||||
|
|
||||
|
export function encodeQuery(query?: Query) { |
||||
|
if (Types.isEmpty(query)) { |
||||
|
return ''; |
||||
|
} |
||||
|
|
||||
|
query = { ...query }; |
||||
|
|
||||
|
if (!query.sort) { |
||||
|
query.sort = []; |
||||
|
} |
||||
|
|
||||
|
if (!query.filter) { |
||||
|
query.filter = { and: [] }; |
||||
|
} |
||||
|
|
||||
|
return encodeURIComponent(JSON.stringify(query)); |
||||
|
} |
||||
|
|
||||
|
export function hasFilter(query?: Query) { |
||||
|
return !!query && !Types.isEmpty(query.filter); |
||||
|
} |
||||
|
|
||||
|
const EqualOperators: FilterOperator[] = [ |
||||
|
{ name: '==', value: 'eq' }, |
||||
|
{ name: '!=', value: 'ne' } |
||||
|
]; |
||||
|
|
||||
|
const CompareOperator: FilterOperator[] = [ |
||||
|
{ name: '<', value: 'lt' }, |
||||
|
{ name: '<=', value: 'le' }, |
||||
|
{ name: '>', value: 'gt' }, |
||||
|
{ name: '>=', value: 'ge' } |
||||
|
]; |
||||
|
|
||||
|
const StringOperators: FilterOperator[] = [ |
||||
|
{ name: 'T*', value: 'startsWith' }, |
||||
|
{ name: '*T', value: 'endsWith' }, |
||||
|
{ name: '*T*', value: 'contains' } |
||||
|
]; |
||||
|
|
||||
|
const ArrayOperators: FilterOperator[] = [ |
||||
|
{ value: 'empty', noValue: true } |
||||
|
]; |
||||
|
|
||||
|
const TypeBoolean: QueryFieldModel = { |
||||
|
type: 'boolean', |
||||
|
operators: EqualOperators |
||||
|
}; |
||||
|
|
||||
|
const TypeDateTime: QueryFieldModel = { |
||||
|
type: 'datetime', |
||||
|
operators: [...EqualOperators, ...CompareOperator] |
||||
|
}; |
||||
|
|
||||
|
const TypeNumber: QueryFieldModel = { |
||||
|
type: 'number', |
||||
|
operators: [...EqualOperators, ...CompareOperator] |
||||
|
}; |
||||
|
|
||||
|
const TypeReference: QueryFieldModel = { |
||||
|
type: 'reference', |
||||
|
operators: [...EqualOperators, ...ArrayOperators] |
||||
|
}; |
||||
|
|
||||
|
const TypeStatus: QueryFieldModel = { |
||||
|
type: 'status', |
||||
|
operators: EqualOperators |
||||
|
}; |
||||
|
|
||||
|
const TypeString: QueryFieldModel = { |
||||
|
type: 'string', |
||||
|
operators: [...EqualOperators, ...CompareOperator, ...StringOperators, ...ArrayOperators] |
||||
|
}; |
||||
|
|
||||
|
export function queryModelFromSchema(schema: SchemaDetailsDto, languages: LanguageDto[], statuses: StatusInfo[] | undefined) { |
||||
|
let languagesCodes = languages.map(x => x.iso2Code); |
||||
|
|
||||
|
let invariantCodes = ['iv']; |
||||
|
|
||||
|
let model: QueryModel = { |
||||
|
fields: {} |
||||
|
}; |
||||
|
|
||||
|
model.fields['created'] = TypeDateTime; |
||||
|
model.fields['createdBy'] = TypeString; |
||||
|
model.fields['lastModified'] = TypeDateTime; |
||||
|
model.fields['lastModifiedBy'] = TypeString; |
||||
|
model.fields['version'] = TypeNumber; |
||||
|
|
||||
|
if (statuses) { |
||||
|
model.fields['status'] = { ...TypeStatus, extra: statuses }; |
||||
|
} |
||||
|
|
||||
|
for (let field of schema.fields) { |
||||
|
let type: QueryFieldModel | null = null; |
||||
|
|
||||
|
if (field.properties.fieldType === 'Boolean') { |
||||
|
type = TypeBoolean; |
||||
|
} else if (field.properties.fieldType === 'Number') { |
||||
|
type = TypeNumber; |
||||
|
} else if (field.properties.fieldType === 'String') { |
||||
|
type = TypeString; |
||||
|
} else if (field.properties.fieldType === 'DateTime') { |
||||
|
type = TypeDateTime; |
||||
|
} else if (field.properties.fieldType === 'References') { |
||||
|
const extra = { schemaId: field.properties['schemaId'] }; |
||||
|
|
||||
|
type = { ...TypeReference, extra }; |
||||
|
} |
||||
|
|
||||
|
if (type) { |
||||
|
let codes = field.isLocalizable ? languagesCodes : invariantCodes; |
||||
|
|
||||
|
for (let code of codes) { |
||||
|
model.fields[`data.${field.name}.${code}`] = type; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return model; |
||||
|
} |
||||
File diff suppressed because it is too large
Binary file not shown.
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@ -0,0 +1,381 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using NJsonSchema; |
||||
|
using Squidex.Infrastructure.Json.Objects; |
||||
|
using Squidex.Infrastructure.Queries.Json; |
||||
|
using Squidex.Infrastructure.TestHelpers; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.Queries |
||||
|
{ |
||||
|
public sealed class JsonQueryConversionTests |
||||
|
{ |
||||
|
private readonly List<string> errors = new List<string>(); |
||||
|
private readonly JsonSchema schema = new JsonSchema(); |
||||
|
|
||||
|
public JsonQueryConversionTests() |
||||
|
{ |
||||
|
var nested = new JsonSchemaProperty { Title = "nested" }; |
||||
|
|
||||
|
nested.Properties["property"] = new JsonSchemaProperty |
||||
|
{ |
||||
|
Type = JsonObjectType.String |
||||
|
}; |
||||
|
|
||||
|
schema.Properties["boolean"] = new JsonSchemaProperty |
||||
|
{ |
||||
|
Type = JsonObjectType.Boolean |
||||
|
}; |
||||
|
|
||||
|
schema.Properties["datetime"] = new JsonSchemaProperty |
||||
|
{ |
||||
|
Type = JsonObjectType.String, Format = JsonFormatStrings.DateTime |
||||
|
}; |
||||
|
|
||||
|
schema.Properties["guid"] = new JsonSchemaProperty |
||||
|
{ |
||||
|
Type = JsonObjectType.String, Format = JsonFormatStrings.Guid |
||||
|
}; |
||||
|
|
||||
|
schema.Properties["integer"] = new JsonSchemaProperty |
||||
|
{ |
||||
|
Type = JsonObjectType.Integer |
||||
|
}; |
||||
|
|
||||
|
schema.Properties["number"] = new JsonSchemaProperty |
||||
|
{ |
||||
|
Type = JsonObjectType.Number |
||||
|
}; |
||||
|
|
||||
|
schema.Properties["string"] = new JsonSchemaProperty |
||||
|
{ |
||||
|
Type = JsonObjectType.String |
||||
|
}; |
||||
|
|
||||
|
schema.Properties["stringArray"] = new JsonSchemaProperty |
||||
|
{ |
||||
|
Item = new JsonSchema |
||||
|
{ |
||||
|
Type = JsonObjectType.String |
||||
|
}, |
||||
|
Type = JsonObjectType.Array |
||||
|
}; |
||||
|
|
||||
|
schema.Properties["object"] = nested; |
||||
|
|
||||
|
schema.Properties["reference"] = new JsonSchemaProperty |
||||
|
{ |
||||
|
Reference = nested |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_add_error_if_property_does_not_exist() |
||||
|
{ |
||||
|
var json = new { path = "notfound", op = "eq", value = 1 }; |
||||
|
|
||||
|
AssertErrors(json, "Path 'notfound' does not point to a valid property in the model."); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_add_error_if_nested_property_does_not_exist() |
||||
|
{ |
||||
|
var json = new { path = "object.notfound", op = "eq", value = 1 }; |
||||
|
|
||||
|
AssertErrors(json, "'notfound' is not a property of 'nested'."); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData("contains", "contains(datetime, 2012-11-10T09:08:07Z)")] |
||||
|
[InlineData("empty", "empty(datetime)")] |
||||
|
[InlineData("endswith", "endsWith(datetime, 2012-11-10T09:08:07Z)")] |
||||
|
[InlineData("eq", "datetime == 2012-11-10T09:08:07Z")] |
||||
|
[InlineData("ge", "datetime >= 2012-11-10T09:08:07Z")] |
||||
|
[InlineData("gt", "datetime > 2012-11-10T09:08:07Z")] |
||||
|
[InlineData("le", "datetime <= 2012-11-10T09:08:07Z")] |
||||
|
[InlineData("lt", "datetime < 2012-11-10T09:08:07Z")] |
||||
|
[InlineData("ne", "datetime != 2012-11-10T09:08:07Z")] |
||||
|
[InlineData("startswith", "startsWith(datetime, 2012-11-10T09:08:07Z)")] |
||||
|
public void Should_parse_datetime_string_filter(string op, string expected) |
||||
|
{ |
||||
|
var json = new { path = "datetime", op, value = "2012-11-10T09:08:07Z" }; |
||||
|
|
||||
|
AssertFilter(json, expected); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_parse_date_string_filter() |
||||
|
{ |
||||
|
var json = new { path = "datetime", op = "eq", value = "2012-11-10" }; |
||||
|
|
||||
|
AssertFilter(json, "datetime == 2012-11-10T00:00:00Z"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_add_error_if_datetime_string_property_got_invalid_string_value() |
||||
|
{ |
||||
|
var json = new { path = "datetime", op = "eq", value = "invalid" }; |
||||
|
|
||||
|
AssertErrors(json, "Expected ISO8601 DateTime String for path 'datetime', but got invalid String."); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_add_error_if_datetime_string_property_got_invalid_value() |
||||
|
{ |
||||
|
var json = new { path = "datetime", op = "eq", value = 1 }; |
||||
|
|
||||
|
AssertErrors(json, "Expected ISO8601 DateTime String for path 'datetime', but got Number."); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData("contains", "contains(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")] |
||||
|
[InlineData("empty", "empty(guid)")] |
||||
|
[InlineData("endswith", "endsWith(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")] |
||||
|
[InlineData("eq", "guid == bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] |
||||
|
[InlineData("ge", "guid >= bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] |
||||
|
[InlineData("gt", "guid > bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] |
||||
|
[InlineData("le", "guid <= bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] |
||||
|
[InlineData("lt", "guid < bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] |
||||
|
[InlineData("ne", "guid != bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] |
||||
|
[InlineData("startswith", "startsWith(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")] |
||||
|
public void Should_parse_guid_string_filter(string op, string expected) |
||||
|
{ |
||||
|
var json = new { path = "guid", op, value = "bf57d32c-d4dd-4217-8c16-6dcb16975cf3" }; |
||||
|
|
||||
|
AssertFilter(json, expected); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_add_error_if_guid_string_property_got_invalid_string_value() |
||||
|
{ |
||||
|
var json = new { path = "guid", op = "eq", value = "invalid" }; |
||||
|
|
||||
|
AssertErrors(json, "Expected Guid String for path 'guid', but got invalid String."); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_add_error_if_guid_string_property_got_invalid_value() |
||||
|
{ |
||||
|
var json = new { path = "guid", op = "eq", value = 1 }; |
||||
|
|
||||
|
AssertErrors(json, "Expected Guid String for path 'guid', but got Number."); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData("contains", "contains(string, 'Hello')")] |
||||
|
[InlineData("empty", "empty(string)")] |
||||
|
[InlineData("endswith", "endsWith(string, 'Hello')")] |
||||
|
[InlineData("eq", "string == 'Hello'")] |
||||
|
[InlineData("ge", "string >= 'Hello'")] |
||||
|
[InlineData("gt", "string > 'Hello'")] |
||||
|
[InlineData("le", "string <= 'Hello'")] |
||||
|
[InlineData("lt", "string < 'Hello'")] |
||||
|
[InlineData("ne", "string != 'Hello'")] |
||||
|
[InlineData("startswith", "startsWith(string, 'Hello')")] |
||||
|
public void Should_parse_string_filter(string op, string expected) |
||||
|
{ |
||||
|
var json = new { path = "string", op, value = "Hello" }; |
||||
|
|
||||
|
AssertFilter(json, expected); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_add_error_if_string_property_got_invalid_value() |
||||
|
{ |
||||
|
var json = new { path = "string", op = "eq", value = 1 }; |
||||
|
|
||||
|
AssertErrors(json, "Expected String for path 'string', but got Number."); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_parse_string_in_filter() |
||||
|
{ |
||||
|
var json = new { path = "string", op = "in", value = new[] { "Hello" } }; |
||||
|
|
||||
|
AssertFilter(json, "string in ['Hello']"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_parse_nested_string_filter() |
||||
|
{ |
||||
|
var json = new { path = "object.property", op = "in", value = new[] { "Hello" } }; |
||||
|
|
||||
|
AssertFilter(json, "object.property in ['Hello']"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_parse_referenced_string_filter() |
||||
|
{ |
||||
|
var json = new { path = "reference.property", op = "in", value = new[] { "Hello" } }; |
||||
|
|
||||
|
AssertFilter(json, "reference.property in ['Hello']"); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData("eq", "number == 12")] |
||||
|
[InlineData("ge", "number >= 12")] |
||||
|
[InlineData("gt", "number > 12")] |
||||
|
[InlineData("le", "number <= 12")] |
||||
|
[InlineData("lt", "number < 12")] |
||||
|
[InlineData("ne", "number != 12")] |
||||
|
public void Should_parse_number_filter(string op, string expected) |
||||
|
{ |
||||
|
var json = new { path = "number", op, value = 12 }; |
||||
|
|
||||
|
AssertFilter(json, expected); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_add_error_if_number_property_got_invalid_value() |
||||
|
{ |
||||
|
var json = new { path = "number", op = "eq", value = true }; |
||||
|
|
||||
|
AssertErrors(json, "Expected Number for path 'number', but got Boolean."); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_parse_number_in_filter() |
||||
|
{ |
||||
|
var json = new { path = "number", op = "in", value = new[] { 12 } }; |
||||
|
|
||||
|
AssertFilter(json, "number in [12]"); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData("eq", "boolean == True")] |
||||
|
[InlineData("ne", "boolean != True")] |
||||
|
public void Should_parse_boolean_filter(string op, string expected) |
||||
|
{ |
||||
|
var json = new { path = "boolean", op, value = true }; |
||||
|
|
||||
|
AssertFilter(json, expected); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_add_error_if_boolean_property_got_invalid_value() |
||||
|
{ |
||||
|
var json = new { path = "boolean", op = "eq", value = 1 }; |
||||
|
|
||||
|
AssertErrors(json, "Expected Boolean for path 'boolean', but got Number."); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_parse_boolean_in_filter() |
||||
|
{ |
||||
|
var json = new { path = "boolean", op = "in", value = new[] { true } }; |
||||
|
|
||||
|
AssertFilter(json, "boolean in [True]"); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData("empty", "empty(stringArray)")] |
||||
|
[InlineData("eq", "stringArray == 'Hello'")] |
||||
|
[InlineData("ne", "stringArray != 'Hello'")] |
||||
|
public void Should_parse_array_filter(string op, string expected) |
||||
|
{ |
||||
|
var json = new { path = "stringArray", op, value = "Hello" }; |
||||
|
|
||||
|
AssertFilter(json, expected); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_parse_array_in_filter() |
||||
|
{ |
||||
|
var json = new { path = "stringArray", op = "in", value = new[] { "Hello" } }; |
||||
|
|
||||
|
AssertFilter(json, "stringArray in ['Hello']"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_add_error_when_using_array_value_for_non_allowed_operator() |
||||
|
{ |
||||
|
var json = new { path = "string", op = "eq", value = new[] { "Hello" } }; |
||||
|
|
||||
|
AssertErrors(json, "Array value is not allowed for 'Equals' operator and path 'string'."); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_parse_query() |
||||
|
{ |
||||
|
var json = new { skip = 10, take = 20, FullText = "Hello", Filter = new { path = "string", op = "eq", value = "Hello" } }; |
||||
|
|
||||
|
AssertQuery(json, "Filter: string == 'Hello'; FullText: 'Hello'; Skip: 10; Take: 20"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_parse_query_with_sorting() |
||||
|
{ |
||||
|
var json = new { sort = new[] { new { path = "string", order = "ascending" } } }; |
||||
|
|
||||
|
AssertQuery(json, "Sort: string Ascending"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_throw_exception_for_invalid_query() |
||||
|
{ |
||||
|
var json = new { sort = new[] { new { path = "invalid", order = "ascending" } } }; |
||||
|
|
||||
|
Assert.Throws<ValidationException>(() => AssertQuery(json, null)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_throw_exception_when_parsing_invalid_json() |
||||
|
{ |
||||
|
var json = "invalid"; |
||||
|
|
||||
|
Assert.Throws<ValidationException>(() => AssertQuery(json, null)); |
||||
|
} |
||||
|
|
||||
|
private void AssertQuery(object json, string expectedFilter) |
||||
|
{ |
||||
|
var filter = ConvertQuery(json); |
||||
|
|
||||
|
Assert.Empty(errors); |
||||
|
|
||||
|
Assert.Equal(expectedFilter, filter); |
||||
|
} |
||||
|
|
||||
|
private void AssertFilter(object json, string expectedFilter) |
||||
|
{ |
||||
|
var filter = ConvertFilter(json); |
||||
|
|
||||
|
Assert.Empty(errors); |
||||
|
|
||||
|
Assert.Equal(expectedFilter, filter); |
||||
|
} |
||||
|
|
||||
|
private void AssertErrors(object json, params string[] expectedErrors) |
||||
|
{ |
||||
|
var filter = ConvertFilter(json); |
||||
|
|
||||
|
Assert.Equal(expectedErrors.ToList(), errors); |
||||
|
|
||||
|
Assert.Null(filter); |
||||
|
} |
||||
|
|
||||
|
private string ConvertFilter<T>(T value) |
||||
|
{ |
||||
|
var json = JsonHelper.DefaultSerializer.Serialize(value, true); |
||||
|
|
||||
|
var jsonFilter = JsonHelper.DefaultSerializer.Deserialize<FilterNode<IJsonValue>>(json); |
||||
|
|
||||
|
return JsonFilterVisitor.Parse(jsonFilter, schema, errors)?.ToString(); |
||||
|
} |
||||
|
|
||||
|
private string ConvertQuery<T>(T value) |
||||
|
{ |
||||
|
var json = JsonHelper.DefaultSerializer.Serialize(value, true); |
||||
|
|
||||
|
var jsonFilter = schema.Parse(json, JsonHelper.DefaultSerializer); |
||||
|
|
||||
|
return jsonFilter.ToString(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue