Query Result
diff --git a/src/app/pages/query-page/query-page.component.ts b/src/app/pages/query-page/query-page.component.ts
index 9e0d00d..e5a8994 100644
--- a/src/app/pages/query-page/query-page.component.ts
+++ b/src/app/pages/query-page/query-page.component.ts
@@ -1,26 +1,7 @@
import { DesmoldSDKService } from 'src/app/services/desmold-sdk/desmold-sdk.service';
-import { Component, OnInit } from '@angular/core';
-import IQuery, {
- RequestedDataType,
- defaultIQuery,
-} from 'src/app/interface/IQuery';
-
-import {
- AbstractControl,
- FormArray,
- FormBuilder,
- FormControl,
- FormGroup,
- ValidationErrors,
- ValidatorFn,
- Validators,
-} from '@angular/forms';
-import { StepperSelectionEvent } from '@angular/cdk/stepper';
-import { MatStepper } from '@angular/material/stepper';
-import { Map as MapboxMap } from 'mapbox-gl';
-import * as MapboxDraw from '@mapbox/mapbox-gl-draw';
-import { MatRadioChange } from '@angular/material/radio';
-import { firstValueFrom } from 'rxjs';
+import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
+import IQuery from 'src/app/interface/IQuery';
+import { Subscription, firstValueFrom } from 'rxjs';
import { environment } from 'src/environments/environment';
import {
IResult,
@@ -30,345 +11,269 @@ import {
defaultIResultTable,
} from 'src/app/interface/IResult';
import { MatSnackBar } from '@angular/material/snack-bar';
+import { Bytes } from 'ethers';
+import { MatDialog } from '@angular/material/dialog';
+import { QueryResumeDialogComponent } from 'src/app/components/query-resume-dialog/query-resume-dialog.component';
+
+interface IQueryState {
+ executing: boolean;
+ checkPoint: number;
-let filterMap: MapboxMap;
-const drawPolygon: MapboxDraw = new MapboxDraw({
- displayControlsDefault: false,
- // Select which mapbox-gl-draw control buttons to add to the map.
- controls: {
- polygon: true,
- trash: true,
- },
- // Set mapbox-gl-draw to draw by default.
- // The user does not have to click the polygon control button first.
- defaultMode: 'draw_polygon',
-});
+ query?: IQuery;
+ requestID?: Bytes;
+}
@Component({
selector: 'app-query-page',
templateUrl: './query-page.component.html',
styleUrls: ['./query-page.component.css'],
})
-export class QueryPageComponent implements OnInit {
- query: IQuery = defaultIQuery();
- result: IResult = defaultIResult();
- completed = false;
+export class QueryPageComponent implements OnInit, OnDestroy {
+ result: IResult;
+
displayedColumns: string[] = ['property', 'value', 'unit', 'time'];
- resultTable: IResultTable[] = defaultIResultTable();
-
- propertyFormGroup: FormGroup = this._fb.group({
- // propertyIRI: ['', [Validators.required, this.validIRIValidator()]],
- propertyIRI: ['', [Validators.required]],
- unitIRI: ['', [Validators.required, this.validIRIValidator()]],
- datatype: [
- RequestedDataType.Integer,
- [Validators.required, Validators.min(0), Validators.max(3)],
- ],
- });
- filtersFormGroup: FormGroup = this._fb.group(
- {
- jsonPathExpression: '',
- dynamicFilterExpression: '',
- geoAltitudeLimits: this._fb.group({ hasMin: false, hasMax: false }),
- geoAltitudeRange: this._fb.group({
- min: [0, [Validators.min(0), Validators.max(10000)]],
- max: [0, [Validators.min(0), Validators.max(10000)]],
- }),
- geoType: 'none',
- },
- { validators: this.maxGreaterThanMin }
- );
- prefixesFormArray: FormArray = this._fb.array([]);
- prefixNames: string[] = [];
+ resultTable: IResultTable[];
+ start = 0;
+
+ stepperIndex = 0;
+
+ private subscriptions: Subscription;
+ private queryStateDict: Record;
+ private currentUserAddress = '';
+ private readonly CACHE_KEY = 'queryState';
constructor(
- private _fb: FormBuilder,
private desmold: DesmoldSDKService,
- private snackBar: MatSnackBar
- ) {}
+ private snackBar: MatSnackBar,
+ public dialog: MatDialog,
+ private cd: ChangeDetectorRef
+ ) {
+ this.result = defaultIResult();
+ this.resultTable = defaultIResultTable();
+ this.subscriptions = new Subscription();
+
+ // Check the cache for pre-existing data or initialise with an empty object:
+ const cachedValueStr: string = localStorage.getItem(this.CACHE_KEY) ?? '{}';
+ this.queryStateDict = JSON.parse(cachedValueStr) as Record<
+ string,
+ IQueryState
+ >;
+ }
- async ngOnInit(): Promise {
+ public async ngOnInit(): Promise {
await this.desmold.isReady;
if (!this.desmold.desmoHub.isListening) {
await this.desmold.desmoHub.startListeners();
}
- }
- onFilterMapLoad(map: MapboxMap): void {
- filterMap = map;
- map.addControl(drawPolygon);
- map.on('draw.create', (event: any) => {
- const data = drawPolygon.getAll();
- if (event.type === 'draw.create') {
- if (data.features.length > 1) {
- alert('ERROR: you cannot create more than one polygon!');
- for (let i = 1; i < data.features.length; ++i) {
- const identifier = data.features[i].id;
- if (identifier !== undefined) {
- drawPolygon.delete(identifier.toString());
- }
- }
+ this.currentUserAddress = this.desmold.userAddress;
+
+ this.subscriptions.add(
+ this.desmold.accountsChanged.subscribe(async ({ newValue }) => {
+ if (this.result.loading === true) {
+ // User changed wallet during the execution of a query:
+ // the best thing to do is to force the page to reload
+ // so that every piece of code that is still running gets
+ // immediately stopped.
+ window.location.reload();
+ } else {
+ this.currentUserAddress = newValue;
+ await this.checkForUnfinishedQueryExecution();
}
- }
- });
- }
+ })
+ );
- clearPropertyControl(controlName: string) {
- this.propertyFormGroup.controls[controlName].reset();
- }
+ this.subscriptions.add(
+ this.desmold.desmo.queryState.subscribe((state: any) => {
+ if (state.state === 'TASK_UPDATED') {
+ this.notifySentTransaction('The task is processing...');
+ }
+ if (state.state === 'TASK_COMPLETED') {
+ this.stepperIndex = 3;
+ this.notifySentTransaction('The task is completed');
+ }
+ if (state.state === 'TASK_FAILED') {
+ this.stepperIndex = 0;
+ this.notifySentTransaction('The task has failed');
+ }
+ if (state.state === 'TASK_TIMEDOUT') {
+ this.stepperIndex = 0;
+ this.notifySentTransaction('The task took too long to complete');
+ }
+ })
+ );
- clearFilterControl(controlName: string) {
- this.filtersFormGroup.controls[controlName].reset();
+ // Before (potentially) starting a query execution process,
+ // we need to set all needed subscriptions. This is why
+ // the following line is the last one inside this function:
+ await this.checkForUnfinishedQueryExecution();
}
- clearPrefixControl(controlIndex = -1) {
- if (controlIndex >= 0 && controlIndex < this.prefixesFormArray.length) {
- this.prefixesFormArray.controls[controlIndex].reset();
+ private async checkForUnfinishedQueryExecution(): Promise {
+ if (this.queryStateDict[this.currentUserAddress] === undefined) {
+ // Initialize the query state data structure
+ // for the currently-selected user:
+ this.resetQueryState();
}
- throw new Error('Index out-of-bounds for the prefixesFormArray!');
- }
- getPrefixControl(index = -1): FormControl {
- if (index >= 0 && index < this.prefixesFormArray.length) {
- return this.prefixesFormArray.controls[index] as FormControl;
+ if (this.queryStateDict[this.currentUserAddress].checkPoint >= 0) {
+ const dialogRef = this.dialog.open(QueryResumeDialogComponent, {
+ disableClose: true,
+ autoFocus: true,
+ });
+
+ dialogRef.afterClosed().subscribe(async (result) => {
+ if (result) {
+ await this.continueQueryExecution();
+ } else {
+ this.resetQueryState();
+ }
+ });
}
- throw new Error('Index out-of-bounds for the prefixesFormArray!');
}
- isSelectedGeoTypeNone(): boolean {
- return this.filtersFormGroup.value.geoType === 'none';
- }
+ public async executeQuery(query: IQuery): Promise {
+ if (this.queryStateDict[this.currentUserAddress].checkPoint >= 0) {
+ throw new Error(
+ "Cannot execute query until the current one isn't finished."
+ );
+ }
- async submitQuery() {
- const start: number = this.now();
this.result.loading = true;
this.resultTable = defaultIResultTable();
- this.query = defaultIQuery();
- // Prefix list
+ // Reset the query state for the currently-selected user
+ // and save checkPoint "zero":
+ this.resetQueryState();
+ this.saveCheckPointZero(query);
- for (let i = 0; i < this.prefixesFormArray.length; ++i) {
- this.query.prefixList?.push({
- abbreviation: this.prefixNames[i],
- completeURI: this.prefixesFormArray.controls[i].value,
- });
- }
+ // Execute the first phase of the query execution process
+ // and save checkPoint "one":
+ const requestID: Bytes = await this.executeFirstPhase();
+ this.saveCheckPointOne(requestID);
- /*
- If only a single prefix is used, no response is given.
- workaround to fix the following issue: https://github.com/vaimee/desmo-dapp/issues/15
- */
- this.query.prefixList?.push({
- abbreviation: 'xsd',
- completeURI: 'http://www.w3.org/2001/XMLSchema/',
- });
+ // Execute the second phase of the query execution process
+ // and terminate:
+ await this.executeSecondPhase(query, requestID);
- // Property
- this.query.property.identifier = this.propertyFormGroup.value.propertyIRI;
- this.query.property.unit = this.propertyFormGroup.value.unitIRI;
- this.query.property.datatype = this.propertyFormGroup.value.datatype;
-
- // Query filters
- this.query.staticFilter = this.filtersFormGroup.value.jsonPathExpression;
-
- // To uncomment when these features will be implemented
- /*
- this.query.dynamicFilter =
- this.filtersFormGroup.value.dynamicFilterExpression;
-
- // GEO filter
- // Altitude range
- const {hasMin, hasMax} = this.filtersFormGroup.value.geoAltitudeLimits;
- const altitudeBounds = this.filtersFormGroup.value.geoAltitudeRange;
- if (hasMin || hasMax) {
- this.query.geoFilter!.altitudeRange = {} as IGeoAltitudeRange;
- if (hasMin) {
- this.query.geoFilter!.altitudeRange!.min = altitudeBounds.min;
- }
- if (hasMax) {
- this.query.geoFilter!.altitudeRange!.max = altitudeBounds.max;
- }
- this.query.geoFilter!.altitudeRange!.unit = "https://qudt.org/vocab/unit/M";
- }
- // Region
- const geoRegionType: string = this.filtersFormGroup.value.geoType;
- if (geoRegionType === 'polygon') {
- const vertices = (drawPolygon.getAll().features[0].geometry as any)
- .coordinates[0];
- // For a polygon to be well-defined, it must contain
- // at least 3 vertices + 1 (the first one repeated at the end):
- if (vertices.length >= 4) {
- this.query.geoFilter!.region = { vertices: [] };
- for (const vertex of vertices) {
- const curVertex: IGeoPosition = {
- longitude: vertex[0],
- latitude: vertex[1],
- };
- this.query.geoFilter!.region.vertices.push(curVertex);
- }
- }
- } else if (geoRegionType === 'circle') {
- // TODO
- }
+ this.result.loading = false;
+ }
- // TODO: other parts of the query...
- */
- console.log(this.query);
+ private saveCheckPointZero(query: IQuery) {
+ this.queryStateDict[this.currentUserAddress].executing = false;
+ this.queryStateDict[this.currentUserAddress].checkPoint = 0;
+ this.queryStateDict[this.currentUserAddress].query = query;
- const eventPromise = firstValueFrom(this.desmold.desmoHub.requestID$);
- await this.desmold.desmoHub.getNewRequestID();
- const event = await eventPromise;
- this.notifySentTransaction('new request ID received');
+ // Persist the current state:
+ localStorage.setItem(this.CACHE_KEY, JSON.stringify(this.queryStateDict));
+ }
- const queryToSend: string = this.queryToSend(this.query);
- await this.desmold.desmo.buyQuery(
- event.requestID,
- queryToSend,
- environment.iExecDAppAddress
- );
- this.notifySentTransaction('Query successfully sent');
- const { result, type } = await this.desmold.desmo.getQueryResult();
- this.notifySentTransaction('Query result received');
- const elapsedTime = this.elapsed(start);
- this.queryCompleted(result, type, elapsedTime);
+ private saveCheckPointOne(requestID: Bytes) {
+ this.queryStateDict[this.currentUserAddress].executing = true;
+ this.queryStateDict[this.currentUserAddress].checkPoint = 1;
+ this.queryStateDict[this.currentUserAddress].requestID = requestID;
+
+ // Persist the current state:
+ localStorage.setItem(this.CACHE_KEY, JSON.stringify(this.queryStateDict));
}
- resetQueryBuilder(stepperObject: MatStepper) {
- stepperObject.reset(); // All the controls are set to null
- this.resultReset();
+ private async continueQueryExecution() {
+ const cachedCheckPoint =
+ this.queryStateDict[this.currentUserAddress].checkPoint;
- // The datatype must be either 0, 1, 2 or 3
- this.propertyFormGroup.controls['datatype'].setValue(
- RequestedDataType.Integer
- );
+ // We must ensure that the checkPoint is >= 0:
+ if (cachedCheckPoint < 0) {
+ throw new Error(
+ "Cannot continue executing a query that didn't even start."
+ );
+ }
- // The geoType must be either 'none', 'polygon' or 'circle'
- this.filtersFormGroup.controls['geoType'].setValue('none');
+ // Update the view to show the query execution UI:
+ this.result.loading = true;
+ this.resultTable = defaultIResultTable();
+ this.stepperIndex = 0;
+ this.cd.detectChanges(); // This is needed to make sure the previous lines have a graphical effect
- this.filtersFormGroup.get('geoAltitudeRange.min')?.setValue(0);
- this.filtersFormGroup.get('geoAltitudeRange.max')?.setValue(0);
- }
+ // If the checkPoint is >= 0, than we're sure that a query was cached:
+ const query = this.queryStateDict[this.currentUserAddress].query;
- handlePrefixes(event: StepperSelectionEvent) {
- this.resultReset(); // The result is no longer valid
- if (event.selectedIndex === 4) {
- // Collect prefixes used by the user:
- const prefixes = new Set();
- const IRIs = [
- this.propertyFormGroup.value.propertyIRI,
- this.propertyFormGroup.value.unitIRI,
- ];
- for (const iri of IRIs) {
- const prefix = this.getPrefix(iri);
- if (prefix !== null) prefixes.add(prefix);
- }
-
- // Prefixes to be removed:
- const prefixesToBeRemoved = new Set(
- this.prefixNames.filter((x) => !prefixes.has(x))
- );
- for (const prefix of prefixesToBeRemoved) {
- const prefixIndex = this.prefixNames.indexOf(prefix);
- this.prefixesFormArray.removeAt(prefixIndex);
- this.prefixNames.splice(prefixIndex, 1);
- }
-
- // Prefixes to be added:
- const prefixesToBeAdded = new Set(
- [...prefixes].filter((x) => !this.prefixNames.includes(x))
+ // The query must be defined at this stage:
+ if (query === undefined) {
+ throw new Error(
+ 'Invalid query state: missing query at checkpoint "zero".'
);
- for (const prefix of prefixesToBeAdded) {
- this.prefixesFormArray.push(
- this._fb.control('', [Validators.required, this.httpUrlValidator()])
- );
- this.prefixNames.push(prefix);
- }
}
- }
- public handleGeoTypeChanged(event: MatRadioChange) {
- if (event.value === 'none') {
- } else if (event.value === 'polygon') {
- } else if (event.value === 'circle') {
- alert('ERROR: unimplemented feature!');
- this.filtersFormGroup.controls['geoType'].setValue('none');
- }
- }
- private getPrefix(data: string): string | null {
- data = data.trim();
- // if it's an URL, it's valid
- //check if it is a valid URI
- if (this.isValidHttpUrl(data)) {
- return null;
+ // Now we need the requestID:
+ let requestID: Bytes | undefined;
+ if (cachedCheckPoint === 0) {
+ // Execute the first phase and save checkPoint "one":
+ requestID = await this.executeFirstPhase();
+ this.saveCheckPointOne(requestID);
+ } else if (cachedCheckPoint === 1) {
+ // If the checkPoint is === 1, than we're sure that a requestID was cached:
+ requestID = this.queryStateDict[this.currentUserAddress].requestID;
}
- const slices: string[] = data.split(':') as string[];
- if (slices.length === 2) {
- return slices[0];
- }
- return null;
- }
-
- private isValidHttpUrl(string: string) {
- let url;
- try {
- url = new URL(string);
- } catch (_) {
- return false;
+ // The requestID must be defined at this stage:
+ if (requestID === undefined) {
+ throw new Error(
+ 'Invalid query state: missing requestID at checkpoint "one".'
+ );
}
- return url.protocol === 'http:' || url.protocol === 'https:';
- }
+ // Execute the second phase:
+ await this.executeSecondPhase(query, requestID);
- private validIRIValidator(): ValidatorFn {
- return (control: AbstractControl): ValidationErrors | null => {
- if (control.value !== null) {
- const IRIString = control.value as string;
- if (this.isValidHttpUrl(IRIString)) {
- return null;
- }
-
- const semicolonIndex = IRIString.indexOf(':');
- if (semicolonIndex >= 0) {
- return null;
- }
- }
- return { invalidIRI: { value: control.value } };
- };
+ // Update the view:
+ this.result.loading = false;
}
- private httpUrlValidator(): ValidatorFn {
- return (control: AbstractControl): ValidationErrors | null => {
- if (control.value !== null) {
- const IRIString = control.value as string;
+ private async executeFirstPhase(): Promise {
+ this.stepperIndex = 0;
+ const eventPromise = firstValueFrom(this.desmold.desmoHub.requestID$);
+ await this.desmold.desmoHub.getNewRequestID();
+ const event = await eventPromise;
+ this.result.requestId = event.requestID;
+ this.notifySentTransaction('New request ID obtained.');
- if (
- this.isValidHttpUrl(IRIString) &&
- (IRIString.endsWith('#') || IRIString.endsWith('/'))
- ) {
- return null;
- }
- }
- return { invalidHttpUrl: { value: control.value } };
- };
+ return event.requestID;
}
- private maxGreaterThanMin(control: AbstractControl): ValidationErrors | null {
- const hasMin = control.get('geoAltitudeLimits.hasMin')?.value;
- const hasMax = control.get('geoAltitudeLimits.hasMax')?.value;
- const min = control.get('geoAltitudeRange.min')?.value;
- const max = control.get('geoAltitudeRange.max')?.value;
- if (hasMin && hasMax && min !== null && max !== null) {
- if (min >= max) {
- return { maxGreaterThanMin: true };
- }
- }
+ private async executeSecondPhase(
+ query: IQuery,
+ requestID: Bytes
+ ): Promise {
+ this.stepperIndex = 1;
+ const queryToSend: string = this.encodeQuery(query);
+ await this.desmold.desmo.buyQuery(
+ requestID,
+ queryToSend,
+ environment.iExecDAppAddress
+ );
+ this.notifySentTransaction('Query successfully sent.');
- return null;
+ this.stepperIndex = 2;
+ try {
+ const { result, type } = await this.desmold.desmo.getQueryResult();
+
+ this.notifySentTransaction('Query result received.');
+ const elapsedTime = this.elapsed(this.start);
+ this.queryCompleted(query, result, type, elapsedTime);
+ this.stepperIndex = 4;
+ } catch {
+ this.notifySentTransaction('Query execution failed.');
+ this.resultReset();
+ } finally {
+ // Query execution terminated (successfully or not).
+ // Let's reset the cached query state!
+ this.resetQueryState();
+ }
}
private queryCompleted(
+ query: IQuery,
value: number | string,
type: QueryResultTypes,
elapsedTime: number
@@ -378,22 +283,33 @@ export class QueryPageComponent implements OnInit {
this.result.data.value = value;
this.result.data.type = type;
this.result.elapsedTime = elapsedTime;
- this.result.query = this.query;
+ this.result.query = query;
const resultTable: IResultTable = {
- property: this.query.property.identifier,
+ property: query.property.identifier,
value: value,
- unit: this.query.property.unit,
+ unit: query.property.unit,
time: this.result.elapsedTime,
};
this.resultTable.push(resultTable);
}
+ private resetQueryState() {
+ const initialQueryState: IQueryState = {
+ executing: false,
+ checkPoint: -1,
+ };
+ this.queryStateDict[this.currentUserAddress] = initialQueryState;
+
+ // Persist the current state:
+ localStorage.setItem(this.CACHE_KEY, JSON.stringify(this.queryStateDict));
+ }
+
private resultReset(): void {
this.result = defaultIResult();
this.resultTable = defaultIResultTable();
}
- private queryToSend(query: IQuery): string {
+ private encodeQuery(query: IQuery): string {
const queryString: string = JSON.stringify(query);
const transformedQuery: string = queryString
.trim()
@@ -402,15 +318,22 @@ export class QueryPageComponent implements OnInit {
console.log(transformedQuery);
return transformedQuery;
}
+
private now(): number {
return new Date().getTime();
}
+
private elapsed(start: number): number {
return this.now() - start;
}
+
private notifySentTransaction(message: string) {
this.snackBar.open(message, 'Dismiss', {
duration: 1000,
});
}
+
+ ngOnDestroy(): void {
+ this.subscriptions.unsubscribe();
+ }
}