diff --git a/api/.eslintrc.js b/api/.eslintrc.js index f6c62bee279534a2a2ce7ee4424670655fbd2a4c..8e34f1ea92b73633f2be969865474d6ca4e5cb9c 100644 --- a/api/.eslintrc.js +++ b/api/.eslintrc.js @@ -20,5 +20,6 @@ module.exports = { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', + 'prettier/prettier': ['error', { "endOfLine": "auto"}, { usePrettierrc: true }], }, }; diff --git a/api/package-lock.json b/api/package-lock.json index a0fb8a8365088a38d2f372e8de42ac083a77386a..88182859b9a42510792332bff648111830b823b3 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -19,6 +19,8 @@ "apollo-server-express": "^3.3.0", "axios": "^0.21.1", "graphql": "^15.5.3", + "graphql-type-json": "^0.3.2", + "jsonata": "^1.8.5", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0" @@ -5581,6 +5583,14 @@ "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" } }, + "node_modules/graphql-type-json": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/graphql-type-json/-/graphql-type-json-0.3.2.tgz", + "integrity": "sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg==", + "peerDependencies": { + "graphql": ">=0.8.0" + } + }, "node_modules/graphql-ws": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.4.0.tgz", @@ -7172,6 +7182,14 @@ "node": ">=6" } }, + "node_modules/jsonata": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/jsonata/-/jsonata-1.8.5.tgz", + "integrity": "sha512-ilDyTBkg6qhNoNVr8PUPzz5GYvRK+REKOM5MdOGzH2y6V4yvPRMegSvbZLpbTtI0QAgz09QM7drDhSHUlwp9pA==", + "engines": { + "node": ">= 8" + } + }, "node_modules/jsonc-parser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", @@ -14879,6 +14897,12 @@ "tslib": "^2.1.0" } }, + "graphql-type-json": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/graphql-type-json/-/graphql-type-json-0.3.2.tgz", + "integrity": "sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg==", + "requires": {} + }, "graphql-ws": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.4.0.tgz", @@ -16079,6 +16103,11 @@ "minimist": "^1.2.5" } }, + "jsonata": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/jsonata/-/jsonata-1.8.5.tgz", + "integrity": "sha512-ilDyTBkg6qhNoNVr8PUPzz5GYvRK+REKOM5MdOGzH2y6V4yvPRMegSvbZLpbTtI0QAgz09QM7drDhSHUlwp9pA==" + }, "jsonc-parser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", diff --git a/api/package.json b/api/package.json index 848e67878898649298ce812ddcef10ec95c47dc1..1885442d70f4ac545ac7fc662ec9bef27d1d21de 100644 --- a/api/package.json +++ b/api/package.json @@ -32,6 +32,8 @@ "apollo-server-express": "^3.3.0", "axios": "^0.21.1", "graphql": "^15.5.3", + "graphql-type-json": "^0.3.2", + "jsonata": "^1.8.5", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0" @@ -76,4 +78,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} \ No newline at end of file +} diff --git a/api/src/engine/connectors/datashield/main.connector.ts b/api/src/engine/connectors/datashield/main.connector.ts index c81cc65fbef911bcb160acc7bdb516ec3aa80e1d..45bf43a1df7f8fc7877f297a69e5edf6eec50d6d 100644 --- a/api/src/engine/connectors/datashield/main.connector.ts +++ b/api/src/engine/connectors/datashield/main.connector.ts @@ -1,14 +1,43 @@ import { Observable } from 'rxjs'; import { IEngineService } from 'src/engine/engine.interfaces'; import { Domain } from 'src/engine/models/domain.model'; +import { ExperimentCreateInput } from 'src/engine/models/experiment/input/experiment-create.input'; +import { + Experiment, + PartialExperiment, +} from 'src/engine/models/experiment/experiment.model'; +import { ListExperiments } from 'src/engine/models/experiment/list-experiments.model'; +import { ExperimentEditInput } from 'src/engine/models/experiment/input/experiment-edit.input'; export default class DataShieldService implements IEngineService { - getDomains(): Domain[] { + createExperiment( + data: ExperimentCreateInput, + isTransient: boolean, + ): Experiment | Promise<Experiment> { throw new Error('Method not implemented.'); } - - demo(): string { - return 'datashield'; + listExperiments( + page: number, + name: string, + ): ListExperiments | Promise<ListExperiments> { + throw new Error('Method not implemented.'); + } + getExperiment(uuid: string): Experiment | Promise<Experiment> { + throw new Error('Method not implemented.'); + } + removeExperiment( + uuid: string, + ): PartialExperiment | Promise<PartialExperiment> { + throw new Error('Method not implemented.'); + } + editExperient( + uuid: string, + expriment: ExperimentEditInput, + ): Experiment | Promise<Experiment> { + throw new Error('Method not implemented.'); + } + getDomains(): Domain[] { + throw new Error('Method not implemented.'); } getActiveUser(): Observable<string> { @@ -19,7 +48,7 @@ export default class DataShieldService implements IEngineService { throw new Error('Method not implemented.'); } - getExperiment(): Observable<string> { + getExperimentAPI(): Observable<string> { throw new Error('Method not implemented.'); } @@ -27,7 +56,7 @@ export default class DataShieldService implements IEngineService { throw new Error('Method not implemented.'); } - editExperiment(): Observable<string> { + editExperimentAPI(): Observable<string> { throw new Error('Method not implemented.'); } diff --git a/api/src/engine/connectors/exareme/converters.ts b/api/src/engine/connectors/exareme/converters.ts index 72e62044aa2241b824b46d376ff8c14c31b35779..63d689f3fecf19bf9987d640bf916df3ec464a0f 100644 --- a/api/src/engine/connectors/exareme/converters.ts +++ b/api/src/engine/connectors/exareme/converters.ts @@ -1,9 +1,20 @@ import { Category } from 'src/engine/models/category.model'; +import { AlgorithmParameter } from 'src/engine/models/experiment/algorithm-parameter.model'; +import { + Experiment, + ResultUnion, +} from 'src/engine/models/experiment/experiment.model'; +import { ExperimentCreateInput } from 'src/engine/models/experiment/input/experiment-create.input'; import { Group } from 'src/engine/models/group.model'; +import { RawResult } from 'src/engine/models/result/raw-result.model'; +import { TableResult } from 'src/engine/models/result/table-result.model'; import { Variable } from 'src/engine/models/variable.model'; +import { Entity } from './interfaces/entity.interface'; +import { ExperimentData } from './interfaces/experiment/experiment.interface'; +import { ResultExperiment } from './interfaces/experiment/result-experiment.interface'; import { Hierarchy } from './interfaces/hierarchy.interface'; import { VariableEntity } from './interfaces/variable-entity.interface'; -import { Entity } from './interfaces/entity.interface'; +import { transformToExperiment, transientToTable } from './transformations'; export const dataToGroup = (data: Hierarchy): Group => { return { @@ -33,3 +44,93 @@ export const dataToVariable = (data: VariableEntity): Variable => { groups: [], }; }; + +const algoParamInputToData = (param: AlgorithmParameter) => { + return { + name: param.name, + value: param.value.join(','), + }; +}; + +export const experimentInputToData = (data: ExperimentCreateInput) => { + return { + algorithm: { + parameters: [ + { + name: 'dataset', + value: data.datasets.join(','), + }, + { + name: 'filter', + value: data.filter, + }, + { + name: 'pathology', + value: data.domain, + }, + { + name: 'y', + value: data.variables.join(','), + }, + ].concat(data.algorithm.parameters.map(algoParamInputToData)), + type: data.algorithm.type ?? 'string', + name: data.algorithm.name, + }, + name: data.name, + }; +}; + +export const descriptiveDataToTableResult = ( + data: ResultExperiment, +): TableResult[] => { + return transientToTable.evaluate(data); +}; + +export const dataToExperiment = (data: ExperimentData): Experiment => { + const expTransform = transformToExperiment.evaluate(data); + + const exp: Experiment = { + ...expTransform, + results: [], + }; + + exp.results = data.result + ? data.result + .map((result) => dataToResult(result, exp.algorithm.name)) + .flat() + : []; + + return exp; +}; + +export const dataToRaw = (result: ResultExperiment): RawResult[] => { + return [ + { + data: result.data, + }, + ]; +}; + +export const dataToResult = ( + result: ResultExperiment, + algo: string, +): Array<typeof ResultUnion> => { + switch (result.type.toLowerCase()) { + case 'application/json': + return dataJSONtoResult(result, algo); + default: + return dataToRaw(result); + } +}; + +export const dataJSONtoResult = ( + result: ResultExperiment, + algo: string, +): Array<typeof ResultUnion> => { + switch (algo.toLowerCase()) { + case 'descriptive_stats': + return descriptiveDataToTableResult(result); + default: + return []; + } +}; diff --git a/api/src/engine/connectors/exareme/interfaces/experiment/experiment.interface.ts b/api/src/engine/connectors/exareme/interfaces/experiment/experiment.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..7412cb0560c9eda88fe61dd4ebbf8128c94e2c62 --- /dev/null +++ b/api/src/engine/connectors/exareme/interfaces/experiment/experiment.interface.ts @@ -0,0 +1,11 @@ +import { ResultExperiment } from './result-experiment.interface'; + +export interface ExperimentData { + name: string; + uuid?: string; + status?: string; + createdBy?: string; + shared?: boolean; + viewed?: boolean; + result?: ResultExperiment[]; +} diff --git a/api/src/engine/connectors/exareme/interfaces/experiment/experiments.interface.ts b/api/src/engine/connectors/exareme/interfaces/experiment/experiments.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ef0c0f84568d7a7e87dd1f759c1db3b17e8066a --- /dev/null +++ b/api/src/engine/connectors/exareme/interfaces/experiment/experiments.interface.ts @@ -0,0 +1,8 @@ +import { ExperimentData } from './experiment.interface'; + +export interface ExperimentsData { + experiments: ExperimentData[]; + currentpage?: number; + totalExperiments?: number; + totalPages?: number; +} diff --git a/api/src/engine/connectors/exareme/interfaces/experiment/result-experiment.interface.ts b/api/src/engine/connectors/exareme/interfaces/experiment/result-experiment.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..e655151c9e7b113a2dbff0402d8e99c61534adee --- /dev/null +++ b/api/src/engine/connectors/exareme/interfaces/experiment/result-experiment.interface.ts @@ -0,0 +1,4 @@ +export interface ResultExperiment { + data: unknown; + type: string; +} diff --git a/api/src/engine/connectors/exareme/main.connector.ts b/api/src/engine/connectors/exareme/main.connector.ts index aa8fd7d531f63c3b44f0c546619889269f8c0dcb..81ed14ab64999708295fd7bbbc38678994d1ba86 100644 --- a/api/src/engine/connectors/exareme/main.connector.ts +++ b/api/src/engine/connectors/exareme/main.connector.ts @@ -1,12 +1,27 @@ import { HttpService } from '@nestjs/axios'; -import { HttpException, HttpStatus } from '@nestjs/common'; +import { BadRequestException, HttpException, HttpStatus } from '@nestjs/common'; import { Request } from 'express'; import { firstValueFrom, map, Observable } from 'rxjs'; import { IEngineOptions, IEngineService } from 'src/engine/engine.interfaces'; import { Domain } from 'src/engine/models/domain.model'; +import { + Experiment, + PartialExperiment, +} from 'src/engine/models/experiment/experiment.model'; +import { ExperimentCreateInput } from 'src/engine/models/experiment/input/experiment-create.input'; +import { ExperimentEditInput } from 'src/engine/models/experiment/input/experiment-edit.input'; +import { ListExperiments } from 'src/engine/models/experiment/list-experiments.model'; import { Group } from 'src/engine/models/group.model'; import { Variable } from 'src/engine/models/variable.model'; -import { dataToCategory, dataToGroup, dataToVariable } from './converters'; +import { + dataToCategory, + dataToExperiment, + dataToGroup, + dataToVariable, + experimentInputToData, +} from './converters'; +import { ExperimentData } from './interfaces/experiment/experiment.interface'; +import { ExperimentsData } from './interfaces/experiment/experiments.interface'; import { Hierarchy } from './interfaces/hierarchy.interface'; import { Pathology } from './interfaces/pathology.interface'; @@ -16,6 +31,71 @@ export default class ExaremeService implements IEngineService { private readonly httpService: HttpService, ) {} + async createExperiment( + data: ExperimentCreateInput, + isTransient = false, + ): Promise<Experiment> { + const form = experimentInputToData(data); + + const path = + this.options.baseurl + `experiments${isTransient ? '/transient' : ''}`; + + const resultAPI = await firstValueFrom( + this.httpService.post<ExperimentData>(path, form), + ); + + return dataToExperiment(resultAPI.data); + } + + async listExperiments(page: number, name: string): Promise<ListExperiments> { + const path = this.options.baseurl + 'experiments'; + + const resultAPI = await firstValueFrom( + this.httpService.get<ExperimentsData>(path, { params: { page, name } }), + ); + + return { + ...resultAPI.data, + experiments: resultAPI.data.experiments.map(dataToExperiment), + }; + } + + async getExperiment(uuid: string): Promise<Experiment> { + const path = this.options.baseurl + `experiments/${uuid}`; + + const resultAPI = await firstValueFrom( + this.httpService.get<ExperimentData>(path), + ); + + return dataToExperiment(resultAPI.data); + } + + async editExperient( + uuid: string, + expriment: ExperimentEditInput, + ): Promise<Experiment> { + const path = this.options.baseurl + `experiments/${uuid}`; + + const resultAPI = await firstValueFrom( + this.httpService.patch<ExperimentData>(path, expriment), + ); + + return dataToExperiment(resultAPI.data); + } + + async removeExperiment(uuid: string): Promise<PartialExperiment> { + const path = this.options.baseurl + `experiments/${uuid}`; + + try { + await firstValueFrom(this.httpService.delete(path)); + return { + uuid: uuid, + }; + } catch (error) { + throw new BadRequestException(`${uuid} does not exists`); + } + } + async getDomains(ids: string[]): Promise<Domain[]> { const path = this.options.baseurl + 'pathologies'; @@ -48,10 +128,6 @@ export default class ExaremeService implements IEngineService { } } - demo(): string { - return 'exareme'; - } - getActiveUser(): Observable<string> { const path = this.options.baseurl + 'activeUser'; @@ -68,7 +144,7 @@ export default class ExaremeService implements IEngineService { .pipe(map((response) => response.data)); } - getExperiment(uuid: string): Observable<string> { + getExperimentAPI(uuid: string): Observable<string> { const path = this.options.baseurl + `experiments/${uuid}`; return this.httpService @@ -82,11 +158,11 @@ export default class ExaremeService implements IEngineService { return this.httpService.delete(path).pipe(map((response) => response.data)); } - editExperiment(uuid: string, request: Request): Observable<string> { + editExperimentAPI(uuid: string, request: Request): Observable<string> { const path = this.options.baseurl + `experiments/${uuid}`; return this.httpService - .post(path, request.body) + .patch(path, request.body) .pipe(map((response) => response.data)); } @@ -106,22 +182,23 @@ export default class ExaremeService implements IEngineService { .pipe(map((response) => response.data)); } - getExperiments(): Observable<string> { + getExperiments(request: Request): Observable<string> { const path = this.options.baseurl + 'experiments'; return this.httpService - .get<string>(path) + .get<string>(path, { params: request.query }) .pipe(map((response) => response.data)); } - getAlgorithms(): Observable<string> { + getAlgorithms(request: Request): Observable<string> { const path = this.options.baseurl + 'algorithms'; return this.httpService - .get<string>(path) + .get<string>(path, { params: request.query }) .pipe(map((response) => response.data)); } + // UTILITIES private flattenGroups = (data: Hierarchy): Group[] => { let groups: Group[] = [dataToGroup(data)]; diff --git a/api/src/engine/connectors/exareme/transformations.ts b/api/src/engine/connectors/exareme/transformations.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f574db08df84e0023b924ec987809d025e6e4e1 --- /dev/null +++ b/api/src/engine/connectors/exareme/transformations.ts @@ -0,0 +1,76 @@ +// This file contains all transformation queries for JSONata +// see : https://docs.jsonata.org/ + +import * as jsonata from 'jsonata'; // old import style needed due to 'export = jsonata' + +export const transformToExperiment = jsonata(` +( + $params := ["y", "pathology", "dataset", "filter"]; + + { + "name": name, + "uuid": uuid, + "author": createdBy, + "viewed": viewed, + "status": status, + "createdAt": created, + "finishedAt": finished, + "shared": shared, + "updateAt": updated, + "domains": algorithm.parameters[name = "pathology"].value, + "variables": $split(algorithm.parameters[name = "y"].value, ','), + "filter": algorithm.parameters[name = "filter"].value, + "datasets": $split(algorithm.parameters[name = "dataset"].value, ','), + "algorithm": { + "name": algorithm.name, + "parameters" : + algorithm.parameters[$not(name in $params)].({ + "name": name, + "label": label, + "value": value + }) + } + } +) +`); + +export const transientToTable = jsonata(` +( + $e := function($x, $r) {($x != null) ? $x : ($r ? $r : '')}; + + $fn := function($o, $prefix) { + $type($o) = 'object' ? + $each($o, function($v, $k) {( + $type($v) = 'object' ? { $k: $v.count & ' (' & $v.percentage & '%)' } : { + $k: $v + } + )}) ~> $merge() + : {} + }; + + data.[ + $.single.*@$p#$i.( + $ks := $keys($p.*.data[$type($) = 'object']); + { + 'groupBy' : 'single', + 'name': $keys(%)[$i], + 'headers': $append("", $keys(*)).{ + 'name': $, + 'type': 'string' + }, + 'data' : [ + [$keys(%)[$i], $p.*.($e(num_total))], + ['Datapoints', $p.*.($e(num_datapoints))], + ['Nulls', $p.*.($e(num_nulls))], + $p.*.data.($fn($)) ~> $reduce(function($a, $b) { + $map($ks, function($k) {( + { + $k : [$e($lookup($a,$k), "No data"), $e($lookup($b,$k), "No data")] + } + )}) ~> $merge() + }) ~> $each(function($v, $k) {$append($k,$v)}) + ] + }) + ] +) +`); diff --git a/api/src/engine/engine.controller.ts b/api/src/engine/engine.controller.ts index a495a2c317efa7911dc3f2e43915c88f9fb13830..b3e12d13ff2b2004e576f05b4358991f27eabd08 100644 --- a/api/src/engine/engine.controller.ts +++ b/api/src/engine/engine.controller.ts @@ -19,11 +19,6 @@ export class EngineController { @Inject(ENGINE_SERVICE) private readonly engineService: IEngineService, ) {} - @Get('/test') - getTest(): string { - return this.engineService.demo(); - } - @Get('/algorithms') getAlgorithms(@Req() request: Request): Observable<string> { return this.engineService.getAlgorithms(request); @@ -36,7 +31,7 @@ export class EngineController { @Get('/experiments/:uuid') getExperiment(@Param('uuid') uuid: string): Observable<string> { - return this.engineService.getExperiment(uuid); + return this.engineService.getExperimentAPI(uuid); } @Delete('/experiments/:uuid') @@ -52,7 +47,7 @@ export class EngineController { @Param('uuid') uuid: string, @Req() request: Request, ): Observable<string> { - return this.engineService.editExperiment(uuid, request); + return this.engineService.editExperimentAPI(uuid, request); } @Post('experiments/transient') diff --git a/api/src/engine/engine.interfaces.ts b/api/src/engine/engine.interfaces.ts index 09b86b731d670a83b598936d834b8993f4a8b27f..e754a1fd068a47d1f235fb4ca54a692fd4470342 100644 --- a/api/src/engine/engine.interfaces.ts +++ b/api/src/engine/engine.interfaces.ts @@ -1,6 +1,13 @@ import { Request } from 'express'; import { Observable } from 'rxjs'; import { Domain } from './models/domain.model'; +import { + Experiment, + PartialExperiment, +} from './models/experiment/experiment.model'; +import { ExperimentCreateInput } from './models/experiment/input/experiment-create.input'; +import { ExperimentEditInput } from './models/experiment/input/experiment-edit.input'; +import { ListExperiments } from './models/experiment/list-experiments.model'; export interface IEngineOptions { type: string; @@ -8,19 +15,40 @@ export interface IEngineOptions { } export interface IEngineService { - demo(): string; - + //GraphQL getDomains(ids: string[]): Domain[] | Promise<Domain[]>; + createExperiment( + data: ExperimentCreateInput, + isTransient: boolean, + ): Promise<Experiment> | Experiment; + + listExperiments( + page: number, + name: string, + ): Promise<ListExperiments> | ListExperiments; + + getExperiment(uuid: string): Promise<Experiment> | Experiment; + + removeExperiment( + uuid: string, + ): Promise<PartialExperiment> | PartialExperiment; + + editExperient( + uuid: string, + expriment: ExperimentEditInput, + ): Promise<Experiment> | Experiment; + + // Standard REST API call getAlgorithms(request: Request): Observable<string>; getExperiments(request: Request): Observable<string>; - getExperiment(uuid: string): Observable<string>; + getExperimentAPI(uuid: string): Observable<string>; deleteExperiment(uuid: string, request: Request): Observable<string>; - editExperiment(uuid: string, request: Request): Observable<string>; + editExperimentAPI(uuid: string, request: Request): Observable<string>; startExperimentTransient(request: Request): Observable<string>; diff --git a/api/src/engine/engine.resolver.ts b/api/src/engine/engine.resolver.ts index beeb589fc6de4f3294efffea16e4b55c1ac96f61..dfd7dc73ca78f22ce7378ef82895bb99db54f42b 100644 --- a/api/src/engine/engine.resolver.ts +++ b/api/src/engine/engine.resolver.ts @@ -1,8 +1,15 @@ import { Inject } from '@nestjs/common'; -import { Args, Query, Resolver } from '@nestjs/graphql'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { ENGINE_SERVICE } from './engine.constants'; import { IEngineService } from './engine.interfaces'; import { Domain } from './models/domain.model'; +import { + Experiment, + PartialExperiment, +} from './models/experiment/experiment.model'; +import { ExperimentCreateInput } from './models/experiment/input/experiment-create.input'; +import { ExperimentEditInput } from './models/experiment/input/experiment-edit.input'; +import { ListExperiments } from './models/experiment/list-experiments.model'; @Resolver() export class EngineResolver { @@ -17,4 +24,44 @@ export class EngineResolver { ) { return this.engineService.getDomains(ids); } + + @Query(() => ListExperiments) + async experiments( + @Args('page', { nullable: true, defaultValue: 0 }) page: number, + @Args('name', { nullable: true, defaultValue: '' }) name: string, + ) { + return this.engineService.listExperiments(page, name); + } + + @Query(() => Experiment) + async expriment(@Args('uuid') uuid: string) { + return this.engineService.getExperiment(uuid); + } + + @Mutation(() => Experiment) + async createExperiment( + @Args('data') experimentCreateInput: ExperimentCreateInput, + @Args('transient', { nullable: true, defaultValue: false }) + isTransient: boolean, + ) { + return this.engineService.createExperiment( + experimentCreateInput, + isTransient, + ); + } + + @Mutation(() => Experiment) + async editExperiment( + @Args('uuid') uuid: string, + @Args('data') experiment: ExperimentEditInput, + ) { + return this.engineService.editExperient(uuid, experiment); + } + + @Mutation(() => PartialExperiment) + async removeExperiment( + @Args('uuid') uuid: string, + ): Promise<PartialExperiment> { + return this.engineService.removeExperiment(uuid); + } } diff --git a/api/src/engine/models/category.model.ts b/api/src/engine/models/category.model.ts index 877acc5c3c4f5cb3eaf19b05f71879950b69dd76..921ebfce8d518a66a484bf44d04cf330b8091915 100644 --- a/api/src/engine/models/category.model.ts +++ b/api/src/engine/models/category.model.ts @@ -1,10 +1,5 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { ObjectType } from '@nestjs/graphql'; +import { Entity } from './entity.model'; @ObjectType() -export class Category { - @Field() - id: string; - - @Field() - label: string; -} +export class Category extends Entity {} diff --git a/api/src/engine/models/entity.model.ts b/api/src/engine/models/entity.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..32183fd7e970410a1ee3f479cef4d691d56fb7a7 --- /dev/null +++ b/api/src/engine/models/entity.model.ts @@ -0,0 +1,11 @@ +import { Field, InputType, ObjectType } from '@nestjs/graphql'; + +@InputType() +@ObjectType() +export class Entity { + @Field() + id: string; + + @Field({ nullable: true }) + label?: string; +} diff --git a/api/src/engine/models/experiment/algorithm-parameter.model.ts b/api/src/engine/models/experiment/algorithm-parameter.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..79666de79a81e2a7d3c2e2c0756404a995479735 --- /dev/null +++ b/api/src/engine/models/experiment/algorithm-parameter.model.ts @@ -0,0 +1,10 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class AlgorithmParameter { + @Field() + name: string; + + @Field(() => [String]) + value: string[]; +} diff --git a/api/src/engine/models/experiment/algorithm.model.ts b/api/src/engine/models/experiment/algorithm.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..30087c5b55d07d213508d125ba9eb491920073c2 --- /dev/null +++ b/api/src/engine/models/experiment/algorithm.model.ts @@ -0,0 +1,14 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { AlgorithmParameter } from './algorithm-parameter.model'; + +@ObjectType() +export class Algorithm { + @Field() + name: string; + + @Field(() => [AlgorithmParameter], { nullable: true, defaultValue: [] }) + parameters: AlgorithmParameter[]; + + @Field() + type: string; +} diff --git a/api/src/engine/models/experiment/experiment.model.ts b/api/src/engine/models/experiment/experiment.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ac6ec204f2f1021f8f5963771b37362cbce7b18 --- /dev/null +++ b/api/src/engine/models/experiment/experiment.model.ts @@ -0,0 +1,75 @@ +import { + createUnionType, + Field, + ObjectType, + PartialType, +} from '@nestjs/graphql'; +import { RawResult } from '../result/raw-result.model'; +import { TableResult } from '../result/table-result.model'; +import { Algorithm } from './algorithm.model'; + +export const ResultUnion = createUnionType({ + name: 'ResultUnion', + types: () => [TableResult, RawResult], + resolveType(value) { + if (value.headers) { + return TableResult; + } + if (value.listMax) { + return RawResult; + } + + return null; + }, +}); + +@ObjectType() +export class Experiment { + @Field({ nullable: true }) + uuid?: string; + + @Field({ nullable: true, defaultValue: '' }) + author?: string; + + @Field({ nullable: true }) + createdAt?: number; + + @Field({ nullable: true }) + updateAt?: number; + + @Field({ nullable: true }) + finishedAt?: number; + + @Field({ nullable: true, defaultValue: false }) + viewed?: boolean; + + @Field({ nullable: true }) + status?: string; + + @Field({ defaultValue: false }) + shared?: boolean; + + @Field(() => [ResultUnion], { nullable: true, defaultValue: [] }) + results?: Array<typeof ResultUnion>; + + @Field(() => [String]) + datasets: string[]; + + @Field(() => String, { nullable: true }) + filter?: string; + + @Field() + domain: string; + + @Field(() => [String]) + variables: string[]; + + @Field() + algorithm: Algorithm; + + @Field() + name: string; +} + +@ObjectType() +export class PartialExperiment extends PartialType(Experiment) {} diff --git a/api/src/engine/models/experiment/input/algorithm-parameter.input.ts b/api/src/engine/models/experiment/input/algorithm-parameter.input.ts new file mode 100644 index 0000000000000000000000000000000000000000..8eb4cb26b5dade932440b6fef169275988d33b03 --- /dev/null +++ b/api/src/engine/models/experiment/input/algorithm-parameter.input.ts @@ -0,0 +1,10 @@ +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class AlgorithmParamInput { + @Field() + name: string; + + @Field(() => [String]) + value: string[]; +} diff --git a/api/src/engine/models/experiment/input/algorithm.input.ts b/api/src/engine/models/experiment/input/algorithm.input.ts new file mode 100644 index 0000000000000000000000000000000000000000..54d26b7686559c39b311cee5e5496bce530b0cf5 --- /dev/null +++ b/api/src/engine/models/experiment/input/algorithm.input.ts @@ -0,0 +1,14 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { AlgorithmParamInput } from './algorithm-parameter.input'; + +@InputType() +export class AlgorithmInput { + @Field() + name: string; + + @Field(() => [AlgorithmParamInput], { nullable: true, defaultValue: [] }) + parameters: AlgorithmParamInput[]; + + @Field() + type: string; +} diff --git a/api/src/engine/models/experiment/input/experiment-create.input.ts b/api/src/engine/models/experiment/input/experiment-create.input.ts new file mode 100644 index 0000000000000000000000000000000000000000..85a54049cffde188cd770237ba53ef1ccf847027 --- /dev/null +++ b/api/src/engine/models/experiment/input/experiment-create.input.ts @@ -0,0 +1,23 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { AlgorithmInput } from './algorithm.input'; + +@InputType() +export class ExperimentCreateInput { + @Field(() => [String]) + datasets: string[]; + + @Field(() => String, { nullable: true }) + filter: string; + + @Field() + domain: string; + + @Field(() => [String]) + variables: string[]; + + @Field() + algorithm: AlgorithmInput; + + @Field() + name: string; +} diff --git a/api/src/engine/models/experiment/input/experiment-edit.input.ts b/api/src/engine/models/experiment/input/experiment-edit.input.ts new file mode 100644 index 0000000000000000000000000000000000000000..d37deb1e8d008d746fda7214bb6890142b74a12a --- /dev/null +++ b/api/src/engine/models/experiment/input/experiment-edit.input.ts @@ -0,0 +1,10 @@ +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class ExperimentEditInput { + @Field({ nullable: true }) + name?: string; + + @Field({ nullable: true }) + viewed?: boolean; +} diff --git a/api/src/engine/models/experiment/list-experiments.model.ts b/api/src/engine/models/experiment/list-experiments.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f8592e52f1e3d85946d373a73b4edd00ac318f5 --- /dev/null +++ b/api/src/engine/models/experiment/list-experiments.model.ts @@ -0,0 +1,17 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { Experiment } from './experiment.model'; + +@ObjectType() +export class ListExperiments { + @Field({ nullable: true, defaultValue: 0 }) + currentPage?: number; + + @Field({ nullable: true }) + totalPages?: number; + + @Field({ nullable: true }) + totalExperiments?: number; + + @Field(() => [Experiment]) + experiments: Experiment[]; +} diff --git a/api/src/engine/models/group.model.ts b/api/src/engine/models/group.model.ts index d4b45f741c1a9b4b089e8187a136154bd3b695d7..c2087ba2c6023a4a3d7f17110e3e739709d08a90 100644 --- a/api/src/engine/models/group.model.ts +++ b/api/src/engine/models/group.model.ts @@ -1,14 +1,9 @@ import { Field, ObjectType } from '@nestjs/graphql'; +import { Entity } from './entity.model'; import { Variable } from './variable.model'; @ObjectType() -export class Group { - @Field() - id: string; - - @Field() - label: string; - +export class Group extends Entity { @Field({ nullable: true }) description?: string; diff --git a/api/src/engine/models/result/common/header.model.ts b/api/src/engine/models/result/common/header.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..27844983c4724df06cd8d8d79caf83ae4e00a6de --- /dev/null +++ b/api/src/engine/models/result/common/header.model.ts @@ -0,0 +1,10 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class Header { + @Field() + name: string; + + @Field() + type: string; +} diff --git a/api/src/engine/models/result/common/result.model.ts b/api/src/engine/models/result/common/result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca86bfb4e109c44d0d4c06ea0e70ac05e3bf656a --- /dev/null +++ b/api/src/engine/models/result/common/result.model.ts @@ -0,0 +1,7 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export abstract class Result { + @Field({ nullable: true }) + groupBy?: string; +} diff --git a/api/src/engine/models/result/raw-result.model.ts b/api/src/engine/models/result/raw-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..5074e015cc483a3b4fe899f9608d8ddba76f0bfa --- /dev/null +++ b/api/src/engine/models/result/raw-result.model.ts @@ -0,0 +1,12 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { GraphQLJSONObject } from 'graphql-type-json'; +import { Result } from './common/result.model'; + +@ObjectType() +export class RawResult extends Result { + @Field(() => GraphQLJSONObject) + data: unknown; + + @Field(() => [String], { defaultValue: [] }) + listMax?: string[]; +} diff --git a/api/src/engine/models/result/table-result.model.ts b/api/src/engine/models/result/table-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..002d59c5b15ce974e56bc9a9b0d9272212961c31 --- /dev/null +++ b/api/src/engine/models/result/table-result.model.ts @@ -0,0 +1,15 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { Header } from './common/header.model'; +import { Result } from './common/result.model'; + +@ObjectType() +export class TableResult extends Result { + @Field() + name: string; + + @Field(() => [[String]]) + data: string[][]; + + @Field(() => [Header]) + headers: Header[]; +} diff --git a/api/src/engine/models/variable.model.ts b/api/src/engine/models/variable.model.ts index 058feaf0be1e6a94b35c9f9d4ea98ac529ab0e83..e3c4098e31fccbcc4bd223c82ea55225559edaaf 100644 --- a/api/src/engine/models/variable.model.ts +++ b/api/src/engine/models/variable.model.ts @@ -1,15 +1,10 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { Category } from './category.model'; +import { Entity } from './entity.model'; import { Group } from './group.model'; @ObjectType() -export class Variable { - @Field() - id: string; - - @Field({ nullable: true }) - label?: string; - +export class Variable extends Entity { @Field() type: string; diff --git a/api/src/schema.gql b/api/src/schema.gql index 023cdbeda9b5556163b2cd09140be601bd7cbc9c..7f7184b1a4848433930c010edd5aa3090207fd79 100644 --- a/api/src/schema.gql +++ b/api/src/schema.gql @@ -4,7 +4,7 @@ type Category { id: String! - label: String! + label: String } type Variable { @@ -18,7 +18,7 @@ type Variable { type Group { id: String! - label: String! + label: String description: String groups: [Group!]! variables: [Variable!]! @@ -26,7 +26,7 @@ type Group { type Domain { id: String! - label: String! + label: String description: String groups: [Group!]! variables: [Variable!]! @@ -34,6 +34,118 @@ type Domain { rootGroup: Group! } +type Header { + name: String! + type: String! +} + +type AlgorithmParameter { + name: String! + value: [String!]! +} + +type Algorithm { + name: String! + parameters: [AlgorithmParameter!] + type: String! +} + +type Experiment { + uuid: String + author: String + createdAt: Float + updateAt: Float + finishedAt: Float + viewed: Boolean + status: String + shared: Boolean! + results: [ResultUnion!] + datasets: [String!]! + filter: String + domain: String! + variables: [String!]! + algorithm: Algorithm! + name: String! +} + +union ResultUnion = TableResult | RawResult + +type TableResult { + groupBy: String + name: String! + data: [[String!]!]! + headers: [Header!]! +} + +type RawResult { + groupBy: String + data: JSONObject! + listMax: [String!]! +} + +""" +The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +type PartialExperiment { + uuid: String + author: String + createdAt: Float + updateAt: Float + finishedAt: Float + viewed: Boolean + status: String + shared: Boolean + results: [ResultUnion!] + datasets: [String!] + filter: String + domain: String + variables: [String!] + algorithm: Algorithm + name: String +} + +type ListExperiments { + currentPage: Float + totalPages: Float + totalExperiments: Float + experiments: [Experiment!]! +} + type Query { domains(ids: [String!] = []): [Domain!]! + experiments(name: String = "", page: Float = 0): ListExperiments! + expriment(uuid: String!): Experiment! +} + +type Mutation { + createExperiment(transient: Boolean = false, data: ExperimentCreateInput!): Experiment! + editExperiment(data: ExperimentEditInput!, uuid: String!): Experiment! + removeExperiment(uuid: String!): PartialExperiment! +} + +input ExperimentCreateInput { + datasets: [String!]! + filter: String + domain: String! + variables: [String!]! + algorithm: AlgorithmInput! + name: String! +} + +input AlgorithmInput { + name: String! + parameters: [AlgorithmParamInput!] = [] + type: String! +} + +input AlgorithmParamInput { + name: String! + value: [String!]! +} + +input ExperimentEditInput { + name: String + viewed: Boolean }