From f5253966c951865c6bc4b528ed1981cdd5f240ae Mon Sep 17 00:00:00 2001 From: Lajos Date: Tue, 8 Aug 2017 14:16:07 +0200 Subject: [PATCH 1/2] Feature/query api (#41) * fix(Collection): Fixed ContentType fallback - Content * feat(Query): Added Query skeleton * fix(Collection): fixed collection read return type * feat(Query): Added basic expressions and operators, added unit tests * fix(Query): Removed * and ? characters from the escape list (wildcards) * refactor(Query): TypeIs, Type generic argument will be Content by default (workaround for casting to * feat(Query): added Sort, Query(nested), Not(nested), removed root path from query ctor * feat(Query): added Top, Skip * feat(Content, Repository): wired RunQuery() to Repository and Instance level * refactor(Query): removed Exec() with string, added Term expression * test(Query): Added unit tests for AND and OR operators * test(Content): Added unit tests for GetFullPath() * chore(Query): QuerySegment.Finialize() renamed to FinializeSegment() * docs(BaseRepository, Content): RunQuery() docs and examples * docs(Query): Added doc comments * refactor(Query): added FinializedQuery, removed Create() factory methods, modified Content and Repos * docs(Query): Added jsdoc * feat(ContentReferences): added Search() method to ContentReference and ContentListReference * docs(ContentReferences): Added docs and example for Search() * test(ContentReferences): Fixed unit tests with _Text equality check * refactor(package, gulp, typedoc): Removed Gulp dependencies, changed Typedoc script from gulp task t * ci(Travis): removed global gulp install before script * chore(package): cleaned up unused dependencies * fix(ContentReference, ContentListReference): fixed referenceUrl when getting references from root co * fix(Query): fixed minor query syntax errors * chore(ContentReferences): added * wildcards to Search * chore(typings): added references to export * feat(ContentReferences): Search will skip not available types * fix(SN.ts): Query namespace export * refactor(Content, References): Refactored ContentReference and ContentListReference dirtyness checki * refactor(ContentReference, ContentListReference): .update(...) renamed to .handleLoaded(...) * chore(Content): updateReferenceFields removed unreachable code * test(Collection): Added Add() unit test for resolve * docs(Updated BaseRepository API docs, added readme query how-to): --- .travis.yml | 2 +- README.md | 16 ++ gulpfile.js | 68 ------- package.json | 38 +--- src/Authentication/JwtService.ts | 2 - src/Authentication/index.ts | 6 + src/Collection.ts | 67 +++--- src/Config/index.ts | 5 + src/Config/snconfigbehavior.ts | 2 - src/Content.ts | 79 +++++--- src/ContentReferences.ts | 114 +++++++++-- src/HttpProviders/BaseHttpProvider.ts | 2 - src/HttpProviders/index.ts | 5 + src/ODataApi/ODataApi.ts | 3 - src/ODataApi/index.ts | 6 + src/Query/Query.ts | 88 ++++++++ src/Query/QueryResult.ts | 18 ++ src/Query/QuerySegment.ts | 240 ++++++++++++++++++++++ src/Query/index.ts | 10 + src/Repository/BaseRepository.ts | 25 ++- src/Repository/index.ts | 7 + src/SN.ts | 9 + test/CollectionTests.ts | 26 ++- test/ContentListReferenceFieldTests.ts | 5 +- test/ContentReferenceFieldTests.ts | 33 ++- test/ContentTests.ts | 92 ++++----- test/Mocks/MockAuthService.ts | 3 + test/Mocks/MockHttpProvider.ts | 6 +- test/Mocks/MockRepository.ts | 7 +- test/Mocks/MockTokenFactory.ts | 4 + test/Mocks/index.ts | 6 + test/QueryTests.ts | 270 +++++++++++++++++++++++++ test/index.ts | 5 +- tsconfig.typedoc.json | 6 + 34 files changed, 1009 insertions(+), 266 deletions(-) delete mode 100644 gulpfile.js create mode 100644 src/Query/Query.ts create mode 100644 src/Query/QueryResult.ts create mode 100644 src/Query/QuerySegment.ts create mode 100644 src/Query/index.ts create mode 100644 test/QueryTests.ts create mode 100644 tsconfig.typedoc.json diff --git a/.travis.yml b/.travis.yml index ad4ebaa..ec523d2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ node_js: - '7' - '6' before_script: - - npm install -g gulp + - npm --v script: - npm test after_success: diff --git a/README.md b/README.md index a34ec47..70995cb 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,22 @@ There are some Event *Observables* on the **Repository** level which you can sub - OnCustomActionFailed +### Content Queries +You can run queries from a *repository instance* or from a *content instance*. There is a fluent API for creating type safe and valid *Content Queries* +```ts +const query = repository.CreateQuery(q => + q.TypeIs(ContentTypes.Folder) + .And + .Equals('DisplayName', 'a*') + .Top(10)); + +query.Exec() + .subscribe(res => { + console.log('Folders count: ', res.Count); + console.log('Folders: ', res.Result); +} +``` + ### Get the Schema of the given ContentType ```ts diff --git a/gulpfile.js b/gulpfile.js deleted file mode 100644 index eb15721..0000000 --- a/gulpfile.js +++ /dev/null @@ -1,68 +0,0 @@ - -// build chain dependencies -const gulp = require('gulp'); -const typedoc = require("gulp-typedoc"); -var rename = require("gulp-rename"); - -const __coverageThreshold = 60; - - -gulp.task("typedoc", function () { - return gulp - .src([ - "./src/**/*.ts", - "!./src/**/index.ts", - "!./src/SN.ts", - "!./src/SN.d.ts" - ]) - .pipe(typedoc({ - module: "commonjs", - target: "es2015", - includeDeclarations: false, - out: "./documentation/html", - name: "sn-client-js", - theme: "default", - ignoreCompilerErrors: true, - version: true, - mode: "modules", - readme: "sn-client-js/README.md", - excludeExternals: true, - excludePrivate: true, - includes: "docs", - experimentalDecorators: true - })); -}); - - -gulp.task("typedoc:md:generate", function () { - return gulp - .src([ - "./src/**/*.ts", - "!./src/**/index.ts", - "!./src/SN.ts", - "!./src/SN.d.ts" - ]) - .pipe(typedoc({ - module: "commonjs", - target: "es2015", - includeDeclarations: false, - out: "./documentation/markdown", - name: "sn-client-js", - theme: "node_modules/typedoc-md-theme/bin", - ignoreCompilerErrors: true, - version: true, - mode: "modules", - readme: "sn-client-js/README.md", - excludeExternals: true, - excludePrivate: true, - includes: "docs" - })); -}); - -gulp.task('typedoc:md', ['typedoc:md:generate'], ()=>{ - gulp.src('./documentation/markdown/**/*.*') - .pipe(rename((path)=>{ - path.extname= path.extname == '.html' ? '.md' : path.extname - })) - .pipe(gulp.dest('documentation/markdown_renamed')) -}); diff --git a/package.json b/package.json index 9f84b1f..ec0fba0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sn-client-js", - "version": "2.1.0", + "version": "2.1.1-development.9", "description": "A JavaScript client for Sense/Net ECM that makes it easy to use the REST API of the Content Repository.", "main": "dist/src/SN.js", "files": [ @@ -22,9 +22,8 @@ "semantic-release": "semantic-release pre && semantic-release post", "prebuild": "npm run lint && npm run clean", "build": "tsc", - "typedoc:md": "gulp typedoc:md", - "typedoc:html": "gulp typedoc", - "publish:development": "npm t && npm run typedoc:html && npm publish --tag development" + "typedoc": "./node_modules/.bin/typedoc --tsconfig ./tsconfig.typedoc.json --out documentation --excludePrivate --theme default --readme readme.md", + "publish:development": "npm t && npm run typedoc && npm publish --tag development" }, "repository": { "type": "git", @@ -44,7 +43,10 @@ "url": "https://github.com/SenseNet/sn-client-js/issues" }, "nyc": { - "exclude": ["dist/test/**/*.*", "dist/src/**/I*.js"], + "exclude": [ + "dist/test/**/*.*", + "dist/src/**/I*.js" + ], "include": "dist/src/**/*.*", "check-coverage": true, "cache": true, @@ -58,8 +60,7 @@ "homepage": "https://sensenet.com", "dependencies": { "@reactivex/rxjs": "^5.4.2", - "nyc": "^11.0.2", - "ts-json-properties": "1.2.0" + "nyc": "^11.0.2" }, "devDependencies": { "@types/app-root-path": "1.2.4", @@ -67,35 +68,14 @@ "@types/mocha": "2.2.41", "@types/node": "^8.0.0", "chai": "4.1.1", - "codecov.io": "0.1.6", "commitizen": "2.9.6", - "cz-conventional-changelog": "2.0.0", - "del": "3.0.0", - "fs-then-native": "2.0.0", - "gulp": "3.9.1", - "gulp-rename": "^1.2.2", - "gulp-run": "^1.7.1", - "gulp-typedoc": "2.0.2", - "highlight.js": "^9.12.0", - "istanbul": "^0.4.5", - "jsdoc-to-markdown": "^3.0.0", - "lru-cache": "^4.1.1", "mocha": "3.5.0", "mocha-typescript": "^1.0.23", - "natives": "^1.1.0", - "progress": "^2.0.0", "rimraf": "^2.6.1", - "semantic-release": "^6.3.6", - "sigmund": "^1.0.1", - "through2": "^2.0.3", - "ts-json-properties": "^1.2.0", "tslint": "^5.4.3", "typedoc": "^0.8.0", - "typedoc-default-themes": "^0.5.0", - "typedoc-md-theme": "^1.0.1", "typedoc-plugin-external-module-name": "^1.0.9", - "typescript": "2.4.2", - "universalify": "^0.1.0" + "typescript": "2.4.2" }, "czConfig": { "path": "node_modules/cz-conventional-changelog" diff --git a/src/Authentication/JwtService.ts b/src/Authentication/JwtService.ts index d3e9e1b..2eab5cd 100644 --- a/src/Authentication/JwtService.ts +++ b/src/Authentication/JwtService.ts @@ -1,7 +1,5 @@ /** * @module Authentication - * @preferred - * @description This module that contains authentication-related classes, types and interfaces */ /** */ import { LoginState, LoginResponse, RefreshResponse, Token, TokenStore, IAuthenticationService, TokenPersist } from './'; diff --git a/src/Authentication/index.ts b/src/Authentication/index.ts index 8c6a8cf..7529048 100644 --- a/src/Authentication/index.ts +++ b/src/Authentication/index.ts @@ -1,3 +1,9 @@ +/** + * @module Authentication + * @preferred + * @description This module that contains authentication-related classes, types and interfaces + */ /** */ + export * from './IAuthenticationService'; export * from './ITokenPayload'; export * from './JwtService'; diff --git a/src/Collection.ts b/src/Collection.ts index 83ecc06..06134b8 100644 --- a/src/Collection.ts +++ b/src/Collection.ts @@ -24,8 +24,8 @@ export class Collection { * @param { IODataApi } service The service to use as API Endpoint */ constructor(private items: T[], - private repository: BaseRepository, - private readonly contentType: {new(...args: any[]): T} = Content.constructor as {new(...args: any[]): any}) { + private repository: BaseRepository, + private readonly contentType: { new(...args: any[]): T } = Content as { new(...args: any[]): any }) { this.odata = repository.GetODataApi(); } @@ -115,7 +115,6 @@ export class Collection { * }); * ``` */ - public Remove(index: number, permanently?: boolean): Observable; /** * Method to remove an item from a local collection and from the Content Repository through OData REST API at the same time. * @@ -135,11 +134,10 @@ export class Collection { * }); * ``` */ - public Remove(items: number[], permanently?: boolean): Observable; - public Remove(arg: any, permanently: boolean = false): Observable { + public Remove(arg: number | number[], permanently: boolean = false): Observable { if (typeof arg === 'number') { let content = this.items[arg]; - if (content && content.Id){ + if (content && content.Id) { this.items = this.items.slice(0, arg) .concat(this.items.slice(arg + 1)); @@ -181,18 +179,16 @@ export class Collection { */ public Read(path: string, options?: IODataParams): Observable { this.Path = path; - let o = {}; + let o: any = {}; if (typeof options !== 'undefined') { o['params'] = options; } o['path'] = path; let optionList = new ODataRequestOptions(o as ODataRequestOptions); - const children = this.odata.Fetch(optionList); - children - .subscribe( - (items) => { - this.items = items.d.results.map(c => this.repository.HandleLoadedContent(c, this.contentType)); - } + const children = this.odata.Fetch(optionList) + .map(items => { + return items.d.results.map(c => this.repository.HandleLoadedContent(c, this.contentType)); + } ); return children; } @@ -213,7 +209,6 @@ export class Collection { * }); * ``` */ - public Move(index: number, targetPath: string): Observable; /** * Method to move multiple content to another container. * @param items {number[]} number array of content indexes. @@ -231,8 +226,7 @@ export class Collection { * }); * ``` */ - public Move(items: number[], targetPath: string): Observable; - public Move(arg: any, targetPath: string): Observable { + public Move(arg: number | number[], targetPath: string): Observable { if (typeof arg === 'number') { this.items = this.items.slice(0, arg) @@ -265,26 +259,25 @@ export class Collection { * }); * ``` */ - public Copy(index: number, targetPath: string): Observable; + /** - * Method to copy multiple content to another container. - * @param items {number[]} number array of content indexes. - * @params targetPath {string} Path of the target container. - * @returns {Observable} Returns an RxJS observable that you can subscribe of in your code. - * ``` - * let copy = myCollection.Copy([3, 5], '/Root/MyContent/MyFolder'); - * copy - * .subscribe({ - * next: response => { - * //do something after copy - * }, - * error: error => console.error('something wrong occurred: ' + error), - * complete: () => console.log('done'), - * }); - * ``` - */ - public Copy(items: number[], targetPath: string): Observable; - public Copy(arg: any, targetPath: string): Observable { + * Method to copy multiple content to another container. + * @param items {number[]} number array of content indexes. + * @params targetPath {string} Path of the target container. + * @returns {Observable} Returns an RxJS observable that you can subscribe of in your code. + * ``` + * let copy = myCollection.Copy([3, 5], '/Root/MyContent/MyFolder'); + * copy + * .subscribe({ + * next: response => { + * //do something after copy + * }, + * error: error => console.error('something wrong occurred: ' + error), + * complete: () => console.log('done'), + * }); + * ``` + */ + public Copy(arg: number | number[], targetPath: string): Observable { if (typeof arg === 'number') { let action = new CustomAction({ name: 'Copy', id: arg, isAction: true, requiredParams: ['targetPath'] }); return this.odata.CreateCustomAction(action, { data: [{ 'targetPath': targetPath }] }); @@ -311,7 +304,7 @@ export class Collection { * ``` */ public AllowedChildTypes(options?: Object): Observable { - let o = {}; + let o: any = {}; if (options) { o['params'] = options; } @@ -335,7 +328,7 @@ export class Collection { * @returns {Observable} Returns an RxJS observable that you can subscribe of in your code. */ public Upload(contentType: string, fileName: string, overwrite: boolean = true, useChunk: boolean = false, propertyName?: string, fileText?: string): Observable { - const data = { + const data: any = { ContentType: contentType, FileName: fileName, Overwrite: overwrite, diff --git a/src/Config/index.ts b/src/Config/index.ts index a3613e8..db79525 100644 --- a/src/Config/index.ts +++ b/src/Config/index.ts @@ -1,3 +1,8 @@ +/** + * @module Config + * @preferred + * @description Library module for storing configuration related classes and interfaces. + *//** */ export * from './snconfigbehavior'; export * from './snconfigfielddecorator'; export * from './snconfigfieldmodel'; diff --git a/src/Config/snconfigbehavior.ts b/src/Config/snconfigbehavior.ts index 34c7ef8..5ebd3d1 100644 --- a/src/Config/snconfigbehavior.ts +++ b/src/Config/snconfigbehavior.ts @@ -1,7 +1,5 @@ /** * @module Config - * @preferred - * @description Library module for storing configuration related classes and interfaces. *//** */ diff --git a/src/Content.ts b/src/Content.ts index e6b625c..dec567a 100644 --- a/src/Content.ts +++ b/src/Content.ts @@ -51,26 +51,40 @@ import { ContentSerializer } from './ContentSerializer'; import { DeferredObject } from './ComplexTypes'; import { ContentListReferenceField, ContentReferenceField } from './ContentReferences'; import { Workspace, User, ContentType, GenericContent, Group } from './ContentTypes'; +import { QueryExpression, QuerySegment, FinializedQuery } from './Query'; +/** + * Typeguard that determines if the specified Object is a DeferredObject + * @param fieldObject The object that needs to be checked + */ export const isDeferred = (fieldObject: any): fieldObject is DeferredObject => { return fieldObject && fieldObject.__deferred && fieldObject.__deferred.uri && fieldObject.__deferred.uri.length > 0 || false; } +/** + * Typeguard that determines if the specified Object is an IContentOptions instance + * @param object The object that needs to be checked + */ export const isContentOptions = (object: any): object is IContentOptions => { return object && object.Id && object.Path && object.Type && object.Type.length > 0 || false; } -export const isContentOptionList = (objectList: any[]): objectList is IContentOptions[] => { - return objectList && objectList.length !== undefined && objectList.find(o => !isContentOptions(o)) === undefined || false; +/** + * Typeguard that determines if the specified Object is a Content instance + * @param object The object that needs to be checked + */ +export const isContent = (object: any): object is Content => { + return object && object.Id && object.Path && object.Type && object.Type.length > 0 && object.options && isContentOptions(object.options) || false; } -export const isReferenceField = (field: any): field is ContentReferenceField => { - return field && typeof field.getValue === 'function' && typeof field.GetContent === 'function' || false; +/** + * Typeguard that determines if the specified Object is an IContentOptions array + * @param {any[]} objectList The object that needs to be checked + */ +export const isContentOptionList = (objectList: any[]): objectList is IContentOptions[] => { + return objectList && objectList.length !== undefined && objectList.find(o => !isContentOptions(o)) === undefined || false; } -export const isReferenceListField = (field: any): field is ContentListReferenceField => { - return field && typeof field.getValue === 'function' && typeof field.GetContents === 'function' || false; -} export class Content { @@ -116,7 +130,7 @@ export class Content { private _lastSavedFields: T = {} as T; protected UpdateLastSavedFields(newFields: T) { - this._lastSavedFields = newFields; + Object.assign(this._lastSavedFields, newFields); this._isSaved = true; Object.assign(this, newFields); this.updateReferenceFields(); @@ -138,10 +152,15 @@ export class Content { for (let field in this.GetFields()) { const currentField = (this as any)[field]; if (currentField !== this._lastSavedFields[field]) { - if (isReferenceField(currentField)) { - changedFields[field] = currentField.getValue(); - } else if (isReferenceListField(currentField)) { - changedFields[field] = currentField.getValue(); + + if (currentField instanceof ContentReferenceField) { + if (currentField.IsDirty){ + changedFields[field] = currentField.getValue(); + } + } else if (currentField instanceof ContentListReferenceField) { + if (currentField.IsDirty){ + changedFields[field] = currentField.getValue(); + } } else { changedFields[field] = (this as any)[field] } @@ -157,7 +176,6 @@ export class Content { const fieldsToPost = {} as T; this.GetSchema().FieldSettings.forEach(s => { const value = this[s.Name] && this[s.Name].getValue ? this[s.Name].getValue() : this[s.Name]; - ((!skipEmpties && value !== undefined) || (skipEmpties && value)) && (fieldsToPost[s.Name] = value); }); return fieldsToPost; @@ -215,19 +233,10 @@ export class Content { const referenceSettings: FieldSettings.ReferenceFieldSetting[] = this.GetSchema().FieldSettings.filter(f => f instanceof FieldSettings.ReferenceFieldSetting); referenceSettings.push(...[{ Name: 'EffectiveAllowedChildTypes', AllowMultiple: true }, {Name: 'AllowedChildTypes', AllowMultiple: true}]); referenceSettings.forEach(f => { - if (this.IsSaved && !this[f.Name]){ - const generatedDeferred: DeferredObject = { - __deferred: { - uri: ODataHelper.joinPaths(this.GetFullPath(), f.Name) - } - }; - (this[f.Name] as any) = generatedDeferred; - } - if (!this.referenceFieldCache[f.Name]){ - this.referenceFieldCache[f.Name] = f.AllowMultiple ? new ContentListReferenceField(this[f.Name], this.repository) : new ContentReferenceField(this[f.Name], this.repository); + this.referenceFieldCache[f.Name] = f.AllowMultiple ? new ContentListReferenceField(this[f.Name], f, this.repository) : new ContentReferenceField(this[f.Name], f, this.repository); } else { - this.referenceFieldCache[f.Name].update(this[f.Name]); + this.referenceFieldCache[f.Name].handleLoaded(this[f.Name]); } this[f.Name] = this.referenceFieldCache[f.Name]; }); @@ -1768,6 +1777,28 @@ export class Content { * @returns {string} The stringified value */ Stringify: () => string = () => ContentSerializer.Stringify(this); + + /** + * Creates a content query on a Content instance. + * Usage: + * ```ts + * const query = content.CreateQuery(q => q.TypeIs(ContentTypes.Folder) + * .Top(10)) + * query.Exec().subscribe(res => { + * console.log('Folders count: ', res.Count); + * console.log('Folders: ', res.Result); + * } + * + * ``` + * @returns {Observable>} An observable with the Query result. + */ + CreateQuery: (build: (first: QueryExpression) => QuerySegment, params?: ODataParams) => FinializedQuery + = (build, params) => { + if (!this.Path){ + throw new Error('No Content path provided for querying') + } + return new FinializedQuery(build, this.repository, this.Path, params); + }; } /** diff --git a/src/ContentReferences.ts b/src/ContentReferences.ts index 606c1b7..75ae4a2 100644 --- a/src/ContentReferences.ts +++ b/src/ContentReferences.ts @@ -8,7 +8,68 @@ import { DeferredObject } from './ComplexTypes' import { BaseRepository } from './Repository/BaseRepository'; import { Observable } from '@reactivex/rxjs'; import { ODataRequestOptions } from './ODataApi/ODataRequestOptions'; -import { ODataParams } from './ODataApi/ODataParams'; +import { ODataParams, IODataParams } from './ODataApi/ODataParams'; +import { ReferenceFieldSetting } from './FieldSettings'; +import { FinializedQuery } from './Query'; +import { ContentTypes } from './SN'; + +export abstract class ReferenceAbstract { + public readonly abstract FieldSetting: ReferenceFieldSetting; + public readonly abstract Repository: BaseRepository; + + protected isDirty: boolean = false; + public get IsDirty(): boolean{ + return this.isDirty; + } + + /** + * Executes a search query to lookup possible values to the reference field + * @param { string } term This term will be searched in the _Text field + * @param { number } top The Top value for paging + * @param { number } skip The Skip value for paging + * @param { IOdataParams } odataParams The additional OData params (like select, expand, etc...) + * @returns { FinializedQuery } The FinializedQuery instance that can be executed + * + * Example: + * ```ts + * reference.Search('Term').Exec().subscribe(hits=>{ + * console.log(hits); + * }); + * ``` + */ + public Search(term: string, top: number = 10, skip: number = 0, odataParams: IODataParams = {}): FinializedQuery { + return new FinializedQuery(q => { + let query = q.Equals('_Text', `*${term}*`); + if (this.FieldSetting.SelectionRoots && this.FieldSetting.SelectionRoots.length) { + query = query.And.Query(innerTree => { + this.FieldSetting.SelectionRoots && this.FieldSetting.SelectionRoots.forEach((root, index, thisArray) => { + (innerTree as any) = innerTree.InTree(root); + if (index < thisArray.length - 1) + innerTree = (innerTree as any).Or; + }); + return innerTree; + }) + } + + if (this.FieldSetting.AllowedTypes && this.FieldSetting.AllowedTypes.length) { + const foundTypes = this.FieldSetting.AllowedTypes.map(type => ContentTypes[type] as {new(...args: any[])}).filter(a => a !== undefined); + if (foundTypes.length > 0){ + query = query.And.Query(innerTypes => { + foundTypes.forEach((type, index, thisArray) => { + (innerTypes as any) = innerTypes.Type(type); + if (index < thisArray.length - 1) + innerTypes = (innerTypes as any).Or; + }) + return innerTypes; + }) + } + } + return query.Top(top).Skip(skip); + + }, this.Repository, '/Root', odataParams); + } + +} /** * Represents a Reference field on a Content object. Example: @@ -21,7 +82,7 @@ import { ODataParams } from './ODataApi/ODataParams'; * ``` * */ -export class ContentReferenceField { +export class ContentReferenceField extends ReferenceAbstract { private contentReference: T; private referenceUrl: string; @@ -29,8 +90,9 @@ export class ContentReferenceField { * Updates the reference value to another Content * @param {T} content The new Content value */ - SetContent(content: T){ + SetContent(content: T) { this.contentReference = content; + this.isDirty = true; } /** @@ -42,9 +104,9 @@ export class ContentReferenceField { if (this.contentReference !== undefined) { return Observable.of(this.contentReference); } - const request = this.repository.GetODataApi().Get(new ODataRequestOptions({path: this.referenceUrl, params: odataOptions})) + const request = this.Repository.GetODataApi().Get(new ODataRequestOptions({ path: this.referenceUrl, params: odataOptions })) .map(r => { - return r && r.d && this.repository.HandleLoadedContent(r.d); + return r && r.d && this.Repository.HandleLoadedContent(r.d); }).share(); request.subscribe(c => { this.contentReference = c || null; @@ -64,17 +126,20 @@ export class ContentReferenceField { * Updates the reference URL in case of DeferredObject (not-expanded-fields) or populates the Content reference (for expanded fields) from an OData response's Field * @param {DeferredObject | T['options']} fieldData The DeferredObject or ContentOptions data that can be used */ - public update(fieldData: DeferredObject | T['options']) { + public handleLoaded(fieldData: DeferredObject | T['options']) { if (isDeferred(fieldData)) { - this.referenceUrl = fieldData.__deferred.uri.replace(this.repository.Config.ODataToken + '/', ''); + this.referenceUrl = fieldData.__deferred.uri.replace(this.Repository.Config.ODataToken, ''); } else if (isContentOptions(fieldData)) { - this.contentReference = this.repository.HandleLoadedContent(fieldData); + this.contentReference = this.Repository.HandleLoadedContent(fieldData); } + this.isDirty = false; } constructor(fieldData: DeferredObject | T['options'], - private readonly repository: BaseRepository) { - this.update(fieldData); + public readonly FieldSetting: ReferenceFieldSetting, + public readonly Repository: BaseRepository) { + super(); + this.handleLoaded(fieldData); } } @@ -89,7 +154,7 @@ export class ContentReferenceField { * ``` * */ -export class ContentListReferenceField { +export class ContentListReferenceField extends ReferenceAbstract { private contentReferences: T[]; private referenceUrl: string; @@ -98,10 +163,11 @@ export class ContentListReferenceField { * Updates the reference list to another Content list * @param {T[]} content The new list of content */ - SetContent(content: T[]){ + SetContent(content: T[]) { this.contentReferences = content; + this.isDirty = true; } - + /** * Gets the current referenced values. @@ -113,13 +179,13 @@ export class ContentListReferenceField { return Observable.of(this.contentReferences); } // - const request = this.repository.GetODataApi().Fetch(new ODataRequestOptions({ + const request = this.Repository.GetODataApi().Fetch(new ODataRequestOptions({ path: this.referenceUrl, params: odataOptions }), Content).map(resp => { - return resp && resp.d && resp.d.results.map(c => this.repository.HandleLoadedContent(c)) || []; + return resp && resp.d && resp.d.results.map(c => this.Repository.HandleLoadedContent(c)) || []; }).share(); - + request.subscribe(c => { this.contentReferences = c }); @@ -140,16 +206,20 @@ export class ContentListReferenceField { * Updates the reference URL in case of DeferredObject (not-expanded-fields) or populates the Content list references (for expanded fields) from an OData response's field * @param {DeferredObject | T['options'][]} fieldData The DeferredObject or ContentOptions data that can be used */ - public update(fieldData: DeferredObject | T['options'][]) { + public handleLoaded(fieldData: DeferredObject | T['options'][]) { if (isDeferred(fieldData)) { - this.referenceUrl = fieldData.__deferred.uri.replace(this.repository.Config.ODataToken + '/', ''); ; + this.referenceUrl = fieldData.__deferred.uri.replace(this.Repository.Config.ODataToken, ''); } else if (isContentOptionList(fieldData)) { - this.contentReferences = fieldData.map(f => this.repository.HandleLoadedContent(f)); + this.contentReferences = fieldData.map(f => this.Repository.HandleLoadedContent(f)); } + + this.isDirty = false; } - constructor(fieldData: DeferredObject | T['options'][], - private readonly repository: BaseRepository) { - this.update(fieldData); + constructor(fieldData: DeferredObject | T['options'][], + public readonly FieldSetting: ReferenceFieldSetting, + public readonly Repository: BaseRepository) { + super(); + this.handleLoaded(fieldData); } } diff --git a/src/HttpProviders/BaseHttpProvider.ts b/src/HttpProviders/BaseHttpProvider.ts index bcad801..3644d52 100644 --- a/src/HttpProviders/BaseHttpProvider.ts +++ b/src/HttpProviders/BaseHttpProvider.ts @@ -1,7 +1,5 @@ /** * @module HttpProviders - * @preferred - * @description Library module for storing HttpProvider abstracts and implementations. *//** */ import { Observable, AjaxRequest } from '@reactivex/rxjs'; diff --git a/src/HttpProviders/index.ts b/src/HttpProviders/index.ts index 0d8e5f6..839ec0c 100644 --- a/src/HttpProviders/index.ts +++ b/src/HttpProviders/index.ts @@ -1,2 +1,7 @@ +/** + * @module HttpProviders + * @preferred + * @description Library module for storing HttpProvider abstracts and implementations. + *//** */ export * from './BaseHttpProvider'; export * from './RxAjaxHttpProvider'; \ No newline at end of file diff --git a/src/ODataApi/ODataApi.ts b/src/ODataApi/ODataApi.ts index df0ba88..0f0d593 100644 --- a/src/ODataApi/ODataApi.ts +++ b/src/ODataApi/ODataApi.ts @@ -1,8 +1,5 @@ /** * @module ODataApi - * @preferred - * - * @description This module contains OData-related classes and functions. */ /** */ import { BaseHttpProvider } from '../HttpProviders'; diff --git a/src/ODataApi/index.ts b/src/ODataApi/index.ts index f71bcf8..2006fd1 100644 --- a/src/ODataApi/index.ts +++ b/src/ODataApi/index.ts @@ -1,3 +1,9 @@ +/** + * @module ODataApi + * @preferred + * + * @description This module contains OData-related classes and functions. + */ /** */ export * from './CustomAction'; export * from './ODataApi'; export * from './ODataParams'; diff --git a/src/Query/Query.ts b/src/Query/Query.ts new file mode 100644 index 0000000..fe62daf --- /dev/null +++ b/src/Query/Query.ts @@ -0,0 +1,88 @@ +/** + * @module Query + * */ /** */ + +import { Content } from '../Content'; +import { QuerySegment, QueryExpression, QueryResult } from '.'; +import { BaseRepository } from '../Repository/BaseRepository'; +import { ODataRequestOptions, ODataParams } from '../ODataApi'; +import { Observable } from '@reactivex/rxjs'; + +/** + * Represents an instance of a Query expression. + * Usage example: + * ```ts + * const query = new Query(q => q.TypeIs(ContentTypes.Task).And.Equals('DisplayName', 'Test')) + * console.log(query.toString()); // the content query expression + * ``` + */ +export class Query{ + private readonly segments: QuerySegment[] = []; + + /** + * Appends a new QuerySegment to the existing Query + * @param {QuerySegment} newSegment The Segment to be added + */ + public addSegment(newSegment: QuerySegment ) { + this.segments.push(newSegment); + } + + /** + * @returns {String} The Query expression as a sensenet Content Query + */ + public toString(): string{ + return this.segments.map(s => s.toString()).join(''); + } + + constructor(build: (first: QueryExpression) => void) { + const firstExpression = new QueryExpression(this); + build(firstExpression); + } + + /** + * Method that executes the Query and creates an OData request + * @param {BaseRepository} repository The Repository instance + * @param {string} path The Path for the query + * @param {ODataParams} odataParams Additional OData parameters (like $select, $expand, etc...) + * @returns {Observable>} An Observable that will publish the Query result + */ + public Exec(repository: BaseRepository, path: string, odataParams: ODataParams = {}): Observable>{ + odataParams.query = this.toString(); + return repository.GetODataApi().Fetch(new ODataRequestOptions({ + path, + params: odataParams + }), Content) + .map(q => { + return { + Result: q.d.results.map(c => repository.HandleLoadedContent(c)), + Count: q.d.__count + } + }); + } +} + +/** + * Represents a finialized Query instance that has a Repository, path and OData Parameters set up + */ +export class FinializedQuery extends Query{ + constructor(build: (first: QueryExpression) => void, + private readonly repository: BaseRepository, + private readonly path: string, + private readonly odataParams: ODataParams = {}) { + super(build); + } + + /** + * Executes the Query expression + * Usage: + * ```ts + * const query = new Query(q => q.TypeIs(ContentTypes.Task).And.Equals('DisplayName', 'Test')) + * query.Exec().subscribe(result=>{ + * console.log(result); + * }) + * ``` + */ + public Exec(): Observable> { + return super.Exec(this.repository, this.path, this.odataParams); + } +} \ No newline at end of file diff --git a/src/Query/QueryResult.ts b/src/Query/QueryResult.ts new file mode 100644 index 0000000..274b37e --- /dev/null +++ b/src/Query/QueryResult.ts @@ -0,0 +1,18 @@ +/** + * @module Query + * */ /** */ +import { Content } from '../Content'; + +/** + * Represents a Content Query result + */ +export class QueryResult{ + /** + * The result yielded by the Query + */ + Result: T[]; + /** + * The item count + */ + Count: number; +} \ No newline at end of file diff --git a/src/Query/QuerySegment.ts b/src/Query/QuerySegment.ts new file mode 100644 index 0000000..81fa716 --- /dev/null +++ b/src/Query/QuerySegment.ts @@ -0,0 +1,240 @@ +/** + * @module Query + * */ /** */ + +import { Query } from '.'; +import { Content, isContent } from '../Content'; + +/** + * Represents a query expression segment + */ +export class QuerySegment{ + + /** + * Escapes a String value (except '?' and '*' characters for wildcards) + * @param {string} value The String value to be escaped + * @returns {string} The escaped value + */ + protected escapeValue(value: string): string{ + return typeof value === 'string' ? value.replace(/([\!\+\&\|\(\)\[\]\{\}\^\~\:\"])/g, '\\$1') : value; + } + + protected stringValue: string; + + /** + * A '.SORT' Content Query segment + * @param {K} field The name of the field + * @param {boolean} reverse Sort in reverse order, false by default + */ + public Sort(field: K, reverse: boolean = false){ + this.stringValue = ` .${reverse ? 'REVERSESORT' : 'SORT'}:'${field}'`; + return this.FinializeSegment(); + } + + /** + * A '.TOP' Content Query segment + * @param {number} topCount The TOP item count + */ + public Top(topCount: number){ + this.stringValue = ` .TOP:${topCount}`; + return this.FinializeSegment(); + } + + /** + * Adds a '.SKIP' Content Query segment + * @param {number} skipCount Items to skip + */ + + public Skip(skipCount: number){ + this.stringValue = ` .SKIP:${skipCount}`; + return this.FinializeSegment(); + } + + /** + * @returns {string} a segment string value + */ + public toString(){ + return this.stringValue; + } + + constructor(protected readonly queryRef: Query) { + + } + + protected FinializeSegment() { + this.queryRef.addSegment(this); + return new QuerySegment(this.queryRef); + } + +} + +/** + * Represents a sensenet Content Query expression + */ +export class QueryExpression extends QuerySegment { + + /** + * A plain string as Query term + * @param {string} term The Query term + * @returns { QueryOperator } The Next query operator (fluent) + */ + Term(term: string){ + this.stringValue = term; + return this.Finialize(); + } + + /** + * Adds an InTree content query expression + * @param {string | Content } path The path string or content that will be used as a root + * @returns { QueryOperator } The Next query operator (fluent) + */ + InTree(path: string | Content){ + const pathValue = this.escapeValue(isContent(path) && path.Path ? path.Path : path.toString()) + this.stringValue = `InTree:"${pathValue}"`; + return this.Finialize(); + } + + /** + * Adds an InFolder content query expression + * @param {string | Content } path The path string or content that will be used as a root + * @returns { QueryOperator } The Next query operator (fluent) + */ + InFolder(path: string | Content){ + const pathValue = this.escapeValue(isContent(path) && path.Path ? path.Path : path.toString()) + this.stringValue = `InFolder:"${pathValue}"`; + return this.Finialize(); + } + + /** + * Adds a Type content query expression and casts the rest of the expression to a new type + * @param {{ new(...args: any[]): TNewType }} newTypeAssertion The path string or content that will be used as a root + * @returns { QueryOperator } The Next query operator (fluent) + */ + + Type(newTypeAssertion: { new(...args: any[]): TNewType }) { + this.stringValue = `Type:${newTypeAssertion.name}`; + return this.Finialize() + } + + /** + * Adds a TypeIs content query expression and casts the rest of the expression to a new type + * @param {{ new(...args: any[]): TNewType }} newTypeAssertion The path string or content that will be used as a root + * @returns { QueryOperator } The Next query operator (fluent) + */ + TypeIs(newTypeAssertion: { new(...args: any[]): TNewType }) { + this.stringValue = `TypeIs:${newTypeAssertion.name}`; + return this.Finialize() + } + + /** + * Field equality check content query expression (e.g. +FieldName:'value') + * @param { K } FieldName The name of the Field to be checked + * @param { TReturns[K] } value The value that will be checked. You can use '?' and '*' wildcards + * @returns { QueryOperator } The Next query operator (fluent) + */ + Equals(fieldName: K | '_Text', value: TReturns[K]){ + this.stringValue = `${fieldName}:'${this.escapeValue(value)}'`; + return this.Finialize(); + } + + /** + * Field equality and NOT operator combination. (e.g. +NOT(FieldName:'value')) + * @param { K } FieldName The name of the Field to be checked + * @param { TReturns[K] } value The value that will be checked. You can use '?' and '*' wildcards + * @returns { QueryOperator } The Next query operator (fluent) + */ + + NotEquals(fieldName: K, value: TReturns[K]){ + this.stringValue = `NOT(${fieldName}:'${this.escapeValue(value)}')`; + return this.Finialize(); + } + + /** + * Range search query expression + * @param { K } fieldName he name of the Field to be checked + * @param { TReturns[K] } minValue The minimum allowed value + * @param { TReturns[K] } maxValue The maximum allowed value + * @param { boolean } minimumInclusive Lower limit will be inclusive / exclusive + * @param { boolean } maximumInclusive Upper limit will be inclusive / exclusive + */ + Between(fieldName: K, minValue: TReturns[K], maxValue: TReturns[K], minimumInclusive: boolean = false, maximumInclusive: boolean = false){ + this.stringValue = `${fieldName}:${minimumInclusive ? '[' : '{'}'${this.escapeValue(minValue)}' TO '${this.escapeValue(maxValue)}'${maximumInclusive ? ']' : '}'}`; + return this.Finialize(); + } + + + /** + * Greather than query expression (+FieldName:>'value') + * @param { K } fieldName he name of the Field to be checked + * @param { TReturns[K] } minValue The minimum allowed value + * @param { boolean } minimumInclusive Lower limit will be inclusive / exclusive + */ + GreatherThan(fieldName: K, minValue: TReturns[K], minimumInclusive: boolean = false){ + this.stringValue = `${fieldName}:>${minimumInclusive ? '=' : ''}'${this.escapeValue(minValue)}'`; + return this.Finialize(); + } + + + /** + * Less than query expression (+FieldName:<'value') + * @param { K } fieldName he name of the Field to be checked + * @param { TReturns[K] } maxValue The maximum allowed value + * @param { boolean } maximumInclusive Upper limit will be inclusive / exclusive + */ + LessThan(fieldName: K, maxValue: TReturns[K], maximumInclusive: boolean = false){ + this.stringValue = `${fieldName}:<${maximumInclusive ? '=' : ''}'${this.escapeValue(maxValue)}'`; + return this.Finialize(); + } + + /** + * A Nested query expression + * @param {(first: QueryExpression) => QuerySegment)} build The Expression builder method + */ + Query(build: (first: QueryExpression) => QuerySegment){ + const innerQuery = new Query(build); + this.stringValue = `(${innerQuery.toString()})`; + return this.Finialize(); + } + + /** + * A Nested NOT query expression + * @param {(first: QueryExpression) => QuerySegment)} build The Expression builder method + */ + Not(build: (first: QueryExpression) => QuerySegment){ + const innerQuery = new Query(build); + this.stringValue = `NOT(${innerQuery.toString()})`; + return this.Finialize(); + } + + protected Finialize(): QueryOperators { + this.queryRef.addSegment(this); + return new QueryOperators(this.queryRef as any as Query); + } +} + +// And, Or, Etc... +export class QueryOperators extends QuerySegment{ + + /** + * AND Content Query operator + */ + public get And() { + this.stringValue = ' AND '; + return this.Finialize(); + } + + + /** + * OR Content Query operator + */ + public get Or() { + this.stringValue = ' OR '; + return this.Finialize(); + } + + protected Finialize() { + this.queryRef.addSegment(this); + return new QueryExpression(this.queryRef); + } + +} \ No newline at end of file diff --git a/src/Query/index.ts b/src/Query/index.ts new file mode 100644 index 0000000..21d49ef --- /dev/null +++ b/src/Query/index.ts @@ -0,0 +1,10 @@ +/** + * @module Query + * @preferred + * + * @description Classes and Methods for creating, manipulating and executing content queries in sensenet ECM. + * */ /** */ + +export * from './Query' +export * from './QueryResult' +export * from './QuerySegment' \ No newline at end of file diff --git a/src/Repository/BaseRepository.ts b/src/Repository/BaseRepository.ts index 0e204cd..7246b96 100644 --- a/src/Repository/BaseRepository.ts +++ b/src/Repository/BaseRepository.ts @@ -1,7 +1,5 @@ /** * @module Repository - * @preferred - * @description This module stores the Repository (entry-point to sense NET API) related classes, interfaces and functions. */ /** */ @@ -11,13 +9,12 @@ import { BaseHttpProvider } from '../HttpProviders'; import { SnConfigModel } from '../Config/snconfigmodel'; import { ODataRequestOptions } from '../ODataApi'; import { IAuthenticationService } from '../Authentication/'; -import { IODataParams, ODataParams } from '../ODataApi'; import { ContentType } from '../ContentTypes'; import { Content } from '../Content'; -import { ODataApi } from '../ODataApi'; +import { ODataApi, ODataCollectionResponse, IODataParams, ODataParams } from '../ODataApi'; import { ODataHelper, Authentication, ContentTypes } from '../SN'; -import { ODataCollectionResponse } from '../ODataApi'; import { ContentSerializer } from '../ContentSerializer'; +import { QuerySegment, QueryExpression, FinializedQuery } from '../Query'; /** * @@ -253,7 +250,23 @@ export class BaseRepository q.TypeIs(ContentTypes.Folder) + * .Top(10)) + * + * query.Exec().subscribe(res => { + * console.log('Folders count: ', res.Count); + * console.log('Folders: ', res.Result); + * } + * ``` + * @returns {Observable>} An observable with the Query result. + */ + CreateQuery: (build: (first: QueryExpression) => QuerySegment, params?: ODataParams) => FinializedQuery + = (build, params) => new FinializedQuery(build, this, 'Root', params); + } diff --git a/src/Repository/index.ts b/src/Repository/index.ts index afddd9f..bf5f7cd 100644 --- a/src/Repository/index.ts +++ b/src/Repository/index.ts @@ -1,3 +1,10 @@ +/** + * @module Repository + * @preferred + * @description This module stores the Repository (entry-point to sense NET API) related classes, interfaces and functions. + */ +/** */ + export * from './BaseRepository'; export * from './SnRepository'; export * from './RepositoryEventHub'; diff --git a/src/SN.ts b/src/SN.ts index deee330..0904b70 100644 --- a/src/SN.ts +++ b/src/SN.ts @@ -1,9 +1,17 @@ +/** + * @module sn-client-js + * @preferred + * + * @description The main entry module of the package + */ /** */ + import * as Authentication from './Authentication'; import * as ComplexTypes from './ComplexTypes'; import * as Repository from './Repository'; import * as ContentTypes from './ContentTypes'; export * from './Content'; export * from './ContentSerializer'; +export * from './ContentReferences' import * as FieldSettings from './FieldSettings'; export * from './Retrier'; import * as Schemas from './Schemas'; @@ -16,6 +24,7 @@ import * as Security from './Security'; import * as HttpProviders from './HttpProviders'; import * as Config from './Config'; import * as Mocks from '../test/Mocks'; +export * from './Query'; export * from './ControlMapper'; export { diff --git a/test/CollectionTests.ts b/test/CollectionTests.ts index 635bfc5..20e12ae 100644 --- a/test/CollectionTests.ts +++ b/test/CollectionTests.ts @@ -4,28 +4,30 @@ import { Collection } from '../src/Collection'; import { Content } from '../src/Content'; import { MockRepository } from './Mocks/MockRepository'; import { ContentTypes } from '../src/SN'; +import { LoginState } from '../src/Authentication/LoginState'; const expect = Chai.expect; describe('Collection', () => { let collection: Collection; let children: Content[]; - let Repo = new MockRepository(); + let Repo: MockRepository; beforeEach(() => { + Repo = new MockRepository() children = [ - Content.Create({ + Repo.HandleLoadedContent({ Id: 1, Name: 'test1', Path: '/' - }, Content, Repo), - Content.Create({ + }, Content), + Repo.HandleLoadedContent({ Id: 2, Name: 'test2' - }, Content, Repo)]; + }, Content)]; - collection = new Collection(children, Repo, Content); + collection = new Collection(children, Repo); collection.Path = 'https://daily.demo.sensenet.com/lorem'; }); describe('#Items()', () => { @@ -61,6 +63,16 @@ describe('Collection', () => { let content = Content.Create({ DueDate: '2017-06-27T11:11:11Z', Name: '' }, ContentTypes.Task, Repo); expect(collection.Add(content.options)).to.be.instanceof(Observable); }); + + it('Observable should be resolved', (done) => { + let content = Repo.HandleLoadedContent({ DueDate: '2017-06-27T11:11:11Z', Name: '' }, ContentTypes.Task); + Repo.Authentication.stateSubject.next(LoginState.Authenticated); + Repo.httpProviderRef.setResponse({ d: content.GetFields() }); + collection.Add(content.options).subscribe(r => { + done() + }, done) + }); + }); describe('#Remove()', () => { it('should return an observable', () => { @@ -129,5 +141,5 @@ describe('Collection', () => { collection['Path'] = '/workspaces/project'; expect(collection.Read('Task')).to.be.instanceof(Observable); }); - }); + }); }); \ No newline at end of file diff --git a/test/ContentListReferenceFieldTests.ts b/test/ContentListReferenceFieldTests.ts index 07918e1..678ae58 100644 --- a/test/ContentListReferenceFieldTests.ts +++ b/test/ContentListReferenceFieldTests.ts @@ -6,6 +6,7 @@ import { MockRepository } from './Mocks/MockRepository'; import { IContentOptions } from '../src/Content'; import { ContentTypes } from '../src/SN'; import { LoginState } from '../src/Authentication/LoginState'; +import { ReferenceFieldSetting } from '../src/FieldSettings'; const expect = Chai.expect; @@ -24,12 +25,12 @@ export class ContentListReferenceFieldTests { Path: 'root/a/b', Name: 'Name', Type: 'Task' - } as IContentOptions], this.repo); + } as IContentOptions], new ReferenceFieldSetting({}), this.repo); this.unloadedRef = new ContentListReferenceField({ __deferred: { uri: 'a/b/c' } - } as DeferredObject, this.repo); + } as DeferredObject, new ReferenceFieldSetting({}), this.repo); } @test diff --git a/test/ContentReferenceFieldTests.ts b/test/ContentReferenceFieldTests.ts index 2d7c99e..11c3925 100644 --- a/test/ContentReferenceFieldTests.ts +++ b/test/ContentReferenceFieldTests.ts @@ -6,6 +6,8 @@ import { MockRepository } from './Mocks/MockRepository'; import { IContentOptions } from '../src/Content'; import { ContentTypes } from '../src/SN'; import { LoginState } from '../src/Authentication/LoginState'; +import { ReferenceFieldSetting } from '../src/FieldSettings'; +import { FinializedQuery } from '../src/Query/index'; const expect = Chai.expect; @@ -23,12 +25,12 @@ export class ContentReferenceFieldTests { Path: 'root/a/b', Name: 'Name', Type: 'Task' - } as IContentOptions, this.repo); + } as IContentOptions, new ReferenceFieldSetting({}), this.repo); this.unloadedRef = new ContentReferenceField({ __deferred: { uri: 'a/b/c' } - } as DeferredObject, this.repo); + } as DeferredObject, new ReferenceFieldSetting({}), this.repo); } @test @@ -87,4 +89,31 @@ export class ContentReferenceFieldTests { done(); }, err => done) } + + @test + public 'Search should return a FinializedQuery instance'(){ + const search = this.unloadedRef.Search(''); + expect(search).to.be.instanceof(FinializedQuery); + } + + @test + public 'Search query should contain the term and default parameters'(){ + const search = this.unloadedRef.Search('test-term'); + expect(search.toString()).to.be.eq('_Text:\'*test-term*\' .TOP:10 .SKIP:0'); + } + + + @test + public 'Search query should contain selection roots if available'(){ + this.unloadedRef.FieldSetting.SelectionRoots = ['Root/Example1', 'Root/Example2']; + const search = this.unloadedRef.Search('test-term'); + expect(search.toString()).to.be.eq('_Text:\'*test-term*\' AND (InTree:"Root/Example1" OR InTree:"Root/Example2") .TOP:10 .SKIP:0'); + } + + @test + public 'Search query should contain allowed types if available'(){ + this.unloadedRef.FieldSetting.AllowedTypes = ['Task', 'Folder']; + const search = this.unloadedRef.Search('test-term'); + expect(search.toString()).to.be.eq('_Text:\'*test-term*\' AND (Type:Task OR Type:Folder) .TOP:10 .SKIP:0'); + } } diff --git a/test/ContentTests.ts b/test/ContentTests.ts index 789c75d..7364737 100644 --- a/test/ContentTests.ts +++ b/test/ContentTests.ts @@ -3,7 +3,7 @@ import * as Chai from 'chai'; import { Observable } from '@reactivex/rxjs'; import { MockRepository } from './Mocks'; import { LoginState } from '../src/Authentication/LoginState'; -import { isDeferred, isContentOptions, isContentOptionList, isReferenceField, isReferenceListField } from '../src/Content'; +import { isDeferred, isContentOptions, isContentOptionList } from '../src/Content'; import { ContentReferenceField } from '../src/ContentReferences'; const expect = Chai.expect; @@ -88,50 +88,7 @@ describe('Content', () => { }); }); - - describe('#isReferenceField', () => { - it('should return true if a field contains a getValue function and a GetContent function', () => { - const isReferenceFieldValue = isReferenceField({ - getValue: () => { }, - GetContent: () => { } - }); - expect(isReferenceFieldValue).to.be.eq(true); - }); - it('should return false if an object does not have a getValue function', () => { - const isReferenceFieldValue = isReferenceField({ - GetContent: () => { } - }); - expect(isReferenceFieldValue).to.be.eq(false); - }); - it('should return false if an object does not have a GetContent function', () => { - const isReferenceFieldValue = isReferenceField({ - getValue: () => { }, - }); - expect(isReferenceFieldValue).to.be.eq(false); - }); - }); - - describe('#isReferenceListField', () => { - it('should return true if a field contains a getValue function and a GetContents function', () => { - const isReferenceListFieldValue = isReferenceListField({ - getValue: () => { }, - GetContents: () => { } - }); - expect(isReferenceListFieldValue).to.be.eq(true); - }); - it('should return false if an object does not have a getValue function', () => { - const isReferenceListFieldValue = isReferenceListField({ - GetContents: () => { } - }); - expect(isReferenceListFieldValue).to.be.eq(false); - }); - it('should return false if an object does not have a GetContent function', () => { - const isReferenceListFieldValue = isReferenceListField({ - getValue: () => { }, - }); - expect(isReferenceListFieldValue).to.be.eq(false); - }); - }); + }); @@ -233,12 +190,12 @@ describe('Content', () => { } repo.httpProviderRef.setResponse({ d: options }); contentSaved.ReloadFields('Workspace').subscribe(w => { - contentSaved.Workspace && contentSaved.Workspace.update({ + contentSaved.Workspace && contentSaved.Workspace.SetContent(repo.HandleLoadedContent({ Id: 92635, Path: 'Root/MyWorkspace', Type: 'Workspace', Name: 'ExampleWorkspace' - }); + }, ContentTypes.Workspace)); const changes = contentSaved.GetChanges(); expect(Object.keys(changes).length).to.be.eq(1); expect(changes.Workspace && changes.Workspace).to.be.eq('Root/MyWorkspace'); @@ -254,10 +211,10 @@ describe('Content', () => { repo.httpProviderRef.setResponse({ d: options }); contentSaved.ReloadFields('Versions').subscribe(w => { - contentSaved.Versions && contentSaved.Versions.update([options]); + contentSaved.Versions && contentSaved.Versions.SetContent([contentSaved]); const changes = contentSaved.GetChanges(); expect(Object.keys(changes).length).to.be.eq(1); - expect(changes.Versions && changes.Versions[0]).to.be.eq(options.Path); + expect(changes.Versions && (changes.Versions as any)[0]).to.be.eq(options.Path); done(); }, err => done) @@ -911,7 +868,7 @@ describe('Content', () => { it('should throw Error when no Id provided', () => { const invalidContent = repo.HandleLoadedContent({ Name: 'Test' }, ContentTypes.Task); expect(() => { invalidContent.ReloadFields('Name') }).to.throw('Content Id or Path has to be provided') - }); + }); it('should throw Error when no Id provided', (done) => { repo.Authentication.stateSubject.next(LoginState.Authenticated); @@ -1299,4 +1256,39 @@ describe('Content', () => { }); }); + describe('#GetFullPath', () => { + it('should throw if Content is not saved', () => { + expect(() => { + content.GetFullPath(); + }).to.throw('Content has to be saved to get the full Path') + }); + + it('should throw if Content has no Id AND Path', () => { + const c = repo.HandleLoadedContent({ + Name: 'Test' + }) + expect(() => { + c.GetFullPath(); + }).to.throw('Content Id or Path has to be provided to get the full Path') + }); + + it('should return by Id if possible', () => { + const c = repo.HandleLoadedContent({ + Name: 'Test', + Id: 1, + Path: 'Root/Test' + }) + expect(c.GetFullPath()).to.be.eq('/content(1)'); + }); + + + it('should return by Path if Id is not available possible', () => { + const c = repo.HandleLoadedContent({ + Name: 'Test', + Path: 'Root/Test' + }) + expect(c.GetFullPath()).to.be.eq("Root('Test')"); + }); + }) + }); \ No newline at end of file diff --git a/test/Mocks/MockAuthService.ts b/test/Mocks/MockAuthService.ts index 655c079..fe145bd 100644 --- a/test/Mocks/MockAuthService.ts +++ b/test/Mocks/MockAuthService.ts @@ -1,3 +1,6 @@ +/** + * @module Mocks + */ /** */ import { IAuthenticationService, LoginState } from '../../src/Authentication'; import { Observable, BehaviorSubject, ReplaySubject } from '@reactivex/rxjs'; diff --git a/test/Mocks/MockHttpProvider.ts b/test/Mocks/MockHttpProvider.ts index a09a32a..6f7f341 100644 --- a/test/Mocks/MockHttpProvider.ts +++ b/test/Mocks/MockHttpProvider.ts @@ -1,7 +1,7 @@ /** - * @module HttpProviders - *//** */ - + * @module Mocks + */ /** */ + import { Observable, ReplaySubject, AjaxRequest } from '@reactivex/rxjs'; import { BaseHttpProvider } from '../../src/HttpProviders'; diff --git a/test/Mocks/MockRepository.ts b/test/Mocks/MockRepository.ts index 00ead05..576cb11 100644 --- a/test/Mocks/MockRepository.ts +++ b/test/Mocks/MockRepository.ts @@ -1,8 +1,7 @@ /** - * @module Repository - */ -/** */ - + * @module Mocks + */ /** */ + import { BaseRepository } from '../../src/Repository/index'; import { MockHttpProvider } from './MockHttpProvider'; import { SnConfigModel } from '../../src/Config'; diff --git a/test/Mocks/MockTokenFactory.ts b/test/Mocks/MockTokenFactory.ts index 02ae730..2fb2c5f 100644 --- a/test/Mocks/MockTokenFactory.ts +++ b/test/Mocks/MockTokenFactory.ts @@ -1,3 +1,7 @@ +/** + * @module Mocks + */ /** */ + import { Token, ITokenPayload } from '../../src/Authentication'; export class MockTokenFactory { diff --git a/test/Mocks/index.ts b/test/Mocks/index.ts index 8b00f84..119fa89 100644 --- a/test/Mocks/index.ts +++ b/test/Mocks/index.ts @@ -1,3 +1,9 @@ +/** + * @module Mocks + * @preferred + * + * @description Mock implementations for unit testing + */ /** */ export * from './MockAuthService'; export * from './MockHttpProvider'; export * from './MockRepository'; diff --git a/test/QueryTests.ts b/test/QueryTests.ts new file mode 100644 index 0000000..35692e3 --- /dev/null +++ b/test/QueryTests.ts @@ -0,0 +1,270 @@ +import * as Chai from 'chai'; +import { suite, test } from 'mocha-typescript'; +import { Query } from '../src/Query'; +import { ContentTypes } from '../src/SN'; +import { MockRepository } from './Mocks/index'; +import { LoginState } from '../src/Authentication/LoginState'; + +const expect = Chai.expect; + +@suite('Query tests') +export class QueryTests { + @test + public 'Can be constructed'() { + const q = new Query(q => q); + expect(q).to.be.instanceof(Query); + } + + @test + public 'Can be from a repository'(done: MochaDone) { + const repo = new MockRepository() + repo.Authentication.stateSubject.next(LoginState.Authenticated); + repo.httpProviderRef.setResponse({ + d: { + __count: 1, + results: [{ + Id: 1, + Name: 'Test', + Type: 'Folder', + Path: 'Root/Tasks' + }] + } + }) + const query = repo.CreateQuery(q => q.TypeIs(ContentTypes.Folder)); + query.Exec() + .subscribe(res => { + expect(res.Count).to.be.eq(1); + expect(res.Result[0]).to.be.instanceof(ContentTypes.Folder); + expect(res.Result[0].Type).to.be.eq('Folder'); + done(); + }, done); + + expect(query.toString()).to.be.eq('TypeIs:Folder'); + } + + @test + public 'Should throw Error when try to run from a Content without Path'() { + const repo = new MockRepository() + + const content = repo.HandleLoadedContent({ + Id: 3, + Type: 'Folder' + }) + + expect(() => content.CreateQuery(q => q.TypeIs(ContentTypes.Folder))).to.throw('No Content path provided for querying'); + } + + @test + public 'Can be from a Content'(done: MochaDone) { + const repo = new MockRepository() + repo.Authentication.stateSubject.next(LoginState.Authenticated); + repo.httpProviderRef.setResponse({ + d: { + __count: 1, + results: [{ + Id: 1, + Name: 'Test', + Type: 'Folder', + Path: 'Root/Folders' + }] + } + }) + + const content = repo.HandleLoadedContent({ + Id: 3, + Path: 'Root/Content/Folders', + Type: 'Folder' + }) + + const query = content.CreateQuery(q => q.TypeIs(ContentTypes.Folder)) + query.Exec().subscribe(res => { + expect(res.Count).to.be.eq(1); + expect(res.Result[0]).to.be.instanceof(ContentTypes.Folder); + expect(res.Result[0].Type).to.be.eq('Folder'); + done(); + }, done); + + expect(query.toString()).to.be.eq('TypeIs:Folder'); + + + } + + @test + public 'Term syntax'() { + const queryInstance = new Query(q => q.Term('test term')); + expect(queryInstance.toString()).to.be.eq('test term') + } + + @test + public 'TypeIs syntax'() { + const queryInstance = new Query(q => q.TypeIs(ContentTypes.Task)); + expect(queryInstance.toString()).to.be.eq('TypeIs:Task'); + } + + @test + public 'Type syntax'() { + const queryInstance = new Query(q => q.Type(ContentTypes.Task)); + expect(queryInstance.toString()).to.be.eq('Type:Task'); + } + + @test + public 'InFolder with Path'() { + const queryInstance = new Query(q => q.InFolder('a/b/c')); + expect(queryInstance.toString()).to.be.eq('InFolder:"a/b/c"') + } + + @test + public 'InFolder with Content'() { + const repo = new MockRepository(); + const content = repo.CreateContent({ Id: 2, Path: 'a/b/c', Name: 'test', Type: 'Task' }, ContentTypes.Task); + const queryInstance = new Query(q => q.InFolder(content)); + expect(queryInstance.toString()).to.be.eq('InFolder:"a/b/c"') + } + + @test + public 'InTree with Path'() { + const queryInstance = new Query(q => q.InTree('a/b/c')); + expect(queryInstance.toString()).to.be.eq('InTree:"a/b/c"') + } + + @test + public 'InTree with Content'() { + const repo = new MockRepository(); + const content = repo.CreateContent({ Id: 2, Path: 'a/b/c', Name: 'test', Type: 'Task' }, ContentTypes.Task); + const queryInstance = new Query(q => q.InTree(content)); + expect(queryInstance.toString()).to.be.eq('InTree:"a/b/c"') + } + + @test + public 'Equals'() { + const queryInstance = new Query(q => q.Equals('DisplayName', 'test')); + expect(queryInstance.toString()).to.be.eq('DisplayName:\'test\'') + } + + @test + public 'NotEquals'() { + const queryInstance = new Query(q => q.NotEquals('DisplayName', 'test')); + expect(queryInstance.toString()).to.be.eq('NOT(DisplayName:\'test\')') + } + + @test + public 'Between exclusive'() { + const queryInstance = new Query(q => q.Between('Index', 1, 5)); + expect(queryInstance.toString()).to.be.eq('Index:{\'1\' TO \'5\'}') + } + + @test + public 'Between Inclusive'() { + const queryInstance = new Query(q => q.Between('Index', 10, 50, true, true)); + expect(queryInstance.toString()).to.be.eq('Index:[\'10\' TO \'50\']') + } + + @test + public 'GreatherThan Exclusive'() { + const queryInstance = new Query(q => q.GreatherThan('Index', 10)); + expect(queryInstance.toString()).to.be.eq('Index:>\'10\'') + } + + + @test + public 'GreatherThan Inclusive'() { + const queryInstance = new Query(q => q.GreatherThan('Index', 10, true)); + expect(queryInstance.toString()).to.be.eq('Index:>=\'10\'') + } + + @test + public 'LessThan Exclusive'() { + const queryInstance = new Query(q => q.LessThan('Index', 10)); + expect(queryInstance.toString()).to.be.eq('Index:<\'10\'') + } + + + @test + public 'LessThan Inclusive'() { + const queryInstance = new Query(q => q.LessThan('Index', 10, true)); + expect(queryInstance.toString()).to.be.eq('Index:<=\'10\'') + } + + @test + public 'AND syntax'(){ + const queryInstance = new Query(q => q.Equals('Index', 1).And.Equals('DisplayName', 'Test')); + expect(queryInstance.toString()).to.be.eq("Index:'1' AND DisplayName:'Test'") + } + + @test + public 'OR syntax'(){ + const queryInstance = new Query(q => q.Equals('Index', 1).Or.Equals('DisplayName', 'Test')); + expect(queryInstance.toString()).to.be.eq("Index:'1' OR DisplayName:'Test'") + } + + @test + public 'inner Query'() { + const queryInstance = new Query(q => q.Equals('DisplayName', 'Test') + .And + .Query(inner => + inner.Equals('Index', 1) + ) + ) + expect(queryInstance.toString()).to.be.eq("DisplayName:'Test' AND (Index:'1')"); + } + + @test + public 'NOT statement'() { + const queryInstance = new Query(q => q.Equals('DisplayName', 'Test') + .And + .Not(inner => + inner.Equals('Index', 1) + ) + ) + expect(queryInstance.toString()).to.be.eq("DisplayName:'Test' AND NOT(Index:'1')"); + } + + @test + public 'OrderBy'() { + const queryInstance = new Query(q => q.Sort('DisplayName')); + expect(queryInstance.toString()).to.be.eq(" .SORT:'DisplayName'"); + } + + @test + public 'OrderBy Reverse'() { + const queryInstance = new Query(q => q.Sort('DisplayName', true)); + expect(queryInstance.toString()).to.be.eq(" .REVERSESORT:'DisplayName'"); + } + + + @test + public 'Top'() { + const queryInstance = new Query(q => q.Top(50)); + expect(queryInstance.toString()).to.be.eq(' .TOP:50'); + } + + @test + public 'Skip'() { + const queryInstance = new Query(q => q.Skip(10)); + expect(queryInstance.toString()).to.be.eq(' .SKIP:10'); + } + + @test + public 'Issue Example output'(){ + const query = new Query(q => + q.TypeIs(ContentTypes.Task) // adds '+TypeIs:Document' and Typescript type cast + .And + .Equals('DisplayName', 'Unicorn') // adds +Title:Unicorn (TBD: fuzzy/Proximity) + .And + .Between('ModificationDate', '2017-01-01T00:00:00', '2017-02-01T00:00:00') + .Or + .Query(sub => sub //Grouping + .NotEquals('Approvable', true) + .And + .NotEquals('Description', '*alma*') //Contains with wildcards + ) + .Sort('DisplayName') + .Top(5) // adds .TOP:5 + .Skip(10) // adds .SKIP:10 + ); + + expect(query.toString()).to.be + .eq("TypeIs:Task AND DisplayName:'Unicorn' AND ModificationDate:{'2017-01-01T00\\:00\\:00' TO '2017-02-01T00\\:00\\:00'} OR (NOT(Approvable:'true') AND NOT(Description:'*alma*')) .SORT:'DisplayName' .TOP:5 .SKIP:10") + } + +} \ No newline at end of file diff --git a/test/index.ts b/test/index.ts index ca858fe..723bd07 100644 --- a/test/index.ts +++ b/test/index.ts @@ -5,7 +5,6 @@ import * as ContentTests from './ContentTests'; import * as ContentTypeTests from './ContentTypeTests'; import * as ContentReferenceFieldTests from './ContentReferenceFieldTests'; import * as ContentListReferenceFieldTests from './ContentListReferenceFieldTests'; - import * as ContentSerializerTests from './ContentSerializerTests'; import * as ControlMapperTests from './ControlMapperTests'; import * as FieldSettingsTest from './FieldSettingsTest'; @@ -18,6 +17,7 @@ import * as SchemaTests from './SchemaTests'; import * as SnConfigTests from './SnConfigTests'; import * as TokenTests from './TokenTests'; import * as TokenStoreTests from './TokenStoreTests'; +import * as Query from './QueryTests'; export { HttpProviderTests, @@ -38,5 +38,6 @@ export { SchemaTests, SnConfigTests, TokenTests, - TokenStoreTests + TokenStoreTests, + Query } \ No newline at end of file diff --git a/tsconfig.typedoc.json b/tsconfig.typedoc.json new file mode 100644 index 0000000..cbb93e6 --- /dev/null +++ b/tsconfig.typedoc.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "files": [ + "src/SN.ts" + ] +} \ No newline at end of file From 89e7e67513d90ff09731b8eb34628087a5e85ecb Mon Sep 17 00:00:00 2001 From: gallayl Date: Tue, 8 Aug 2017 14:20:02 +0200 Subject: [PATCH 2/2] chore(package): bumped version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ec0fba0..8eeaa82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sn-client-js", - "version": "2.1.1-development.9", + "version": "2.2.0", "description": "A JavaScript client for Sense/Net ECM that makes it easy to use the REST API of the Content Repository.", "main": "dist/src/SN.js", "files": [