diff --git a/src/framework/scroll-container/detect-scroll.directive.ts b/src/framework/scroll-container/detect-scroll.directive.ts new file mode 100644 index 000000000..35a40b228 --- /dev/null +++ b/src/framework/scroll-container/detect-scroll.directive.ts @@ -0,0 +1,12 @@ +import { Directive, HostBinding, HostListener } from "@angular/core"; + +@Directive({ + selector: "[detectScroll]" +}) +export class DetectScrollDirective { + @HostBinding("class.scrolled") scrolled = false; + + @HostListener("scroll", ["$event.target"]) onScroll(target: any) { + this.scrolled = target.scrollTop > 0; + } +} diff --git a/src/framework/typedb-driver/response.ts b/src/framework/typedb-driver/response.ts index 812cdcb95..a287e78fd 100644 --- a/src/framework/typedb-driver/response.ts +++ b/src/framework/typedb-driver/response.ts @@ -69,6 +69,8 @@ export interface ConceptDocumentsQueryResponse extends QueryResponseBase { export type QueryResponse = OkQueryResponse | ConceptRowsQueryResponse | ConceptDocumentsQueryResponse; +export type ApiOkResponse = { ok: OK_RES }; + export type ApiError = { code: string; message: string }; export interface ApiErrorResponse { @@ -80,9 +82,9 @@ export function isApiError(err: any): err is ApiError { return typeof err.code === "string" && typeof err.message === "string"; } -export type ApiResponse = { ok: OK_RES } | ApiErrorResponse; +export type ApiResponse = ApiOkResponse | ApiErrorResponse; -export function isOkResponse(res: ApiResponse): res is { ok: OK_RES } { +export function isOkResponse(res: ApiResponse): res is ApiOkResponse { return "ok" in res; } diff --git a/src/module/query/query-tool.component.html b/src/module/query/query-tool.component.html index 213f48177..81a487d15 100644 --- a/src/module/query/query-tool.component.html +++ b/src/module/query/query-tool.component.html @@ -1,52 +1,35 @@
-
-
- -

History

+
+
+
+ +

Schema

+
-
-
    - @for (entry of state.history.entries; track entry) { -
  1. - - @if (isQueryRun(entry)) { - - - - } - - - -
  2. - } -
+
+
+ + + + + + + + + + + + + +
-
+
@@ -154,5 +137,48 @@

Output

}
+
+
+ +

History

+
+
+
    + @for (entry of state.history.entries; track entry) { +
  1. + + @if (isQueryRun(entry)) { + + + + } +
  2. + } +
+
+
diff --git a/src/module/query/query-tool.component.scss b/src/module/query/query-tool.component.scss index 2326cf790..95fb6c7dd 100644 --- a/src/module/query/query-tool.component.scss +++ b/src/module/query/query-tool.component.scss @@ -10,23 +10,8 @@ @use "typography"; @use "shapes"; -:host { - height: 100%; -} - -.query-page-rough { - height: 100%; - margin-top: 16px; - margin-bottom: 16px; - display: flex; - flex-direction: column; - gap: 16px; -} - .query-page { - height: 100%; - margin-top: 16px; - margin-bottom: 16px; + height: calc(100% - 86px); gap: 4px !important; ::ng-deep app-drag-handle span { @@ -66,6 +51,18 @@ @include shapes.light-source-gradient(primary.$purple, primary.$deep-purple); transition: background 0.1s linear; padding: 16px; + + &:has(.card-header-wrapper) { + padding: 0; + } +} + +.card-header-wrapper { + padding: 16px; + + .card-header { + margin-bottom: 0; + } } .card-header { @@ -76,6 +73,67 @@ height: 32px; } +.schema-pane { + display: flex; + flex-direction: column; +} + +.schema-container { + display: flex; + overflow: auto; + scrollbar-gutter: stable; + overscroll-behavior: contain; + + .gutter { + flex: 0 0 16px; + } + + &.scrolled { + border-top: 1px solid primary.$black-purple; + } + + mat-tree { + padding: 0; + box-sizing: border-box; + height: 100%; + margin: 0; + + .mat-mdc-icon-button { + margin: 0; + padding: 0; + width: 1em; + height: 1em; + } + + mat-tree-node[aria-level="3"] { + margin-left: 32px; + padding-left: 8px !important; /* overrides inline style from Angular Material */ + padding-right: 8px; + align-items: stretch; + border-bottom: 1px solid primary.$light-purple; + border-left: 1px solid primary.$light-purple; + border-right: 1px solid primary.$light-purple; + font-weight: typography.$thin; + + button { + display: none; + } + + &:nth-child(odd) { + background: primary.$black-purple; + } + + &:nth-child(even) { + background: primary.$purple; + } + } + + mat-tree-node[aria-level="2"] + mat-tree-node[aria-level="3"] { + border-top: 1px solid primary.$light-purple; + } + } +} + mat-form-field { width: 100%; } diff --git a/src/module/query/query-tool.component.ts b/src/module/query/query-tool.component.ts index 6c3443a31..307f8eae7 100644 --- a/src/module/query/query-tool.component.ts +++ b/src/module/query/query-tool.component.ts @@ -12,9 +12,11 @@ import { MatButtonModule } from "@angular/material/button"; import { MatButtonToggleModule } from "@angular/material/button-toggle"; import { MatDividerModule } from "@angular/material/divider"; import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatIconModule } from "@angular/material/icon"; import { MatInputModule } from "@angular/material/input"; import { MatSortModule } from "@angular/material/sort"; import { MatTableModule } from "@angular/material/table"; +import { MatTreeModule } from "@angular/material/tree"; import { MatTooltipModule } from "@angular/material/tooltip"; import { RouterLink } from "@angular/router"; import { ResizableDirective } from "@hhangular/resizable"; @@ -22,6 +24,7 @@ import { distinctUntilChanged, filter, first, map, startWith } from "rxjs"; import { otherExampleLinter, TypeQL } from "../../framework/codemirror-lang-typeql"; import { DriverAction, TransactionOperationAction, isQueryRun, isTransactionOperation } from "../../concept/action"; import { basicDark } from "../../framework/code-editor/theme"; +import { DetectScrollDirective } from "../../framework/scroll-container/detect-scroll.directive"; import { SpinnerComponent } from "../../framework/spinner/spinner.component"; import { RichTooltipDirective } from "../../framework/tooltip/rich-tooltip.directive"; import { AppData } from "../../service/app-data.service"; @@ -29,15 +32,16 @@ import { DriverState } from "../../service/driver-state.service"; import { QueryToolState } from "../../service/query-tool-state.service"; import { SnackbarService } from "../../service/snackbar.service"; import { PageScaffoldComponent } from "../scaffold/page/page-scaffold.component"; +import { SchemaTreeNodeComponent } from "./schema-tree-node/schema-tree-node.component"; @Component({ selector: "ts-query-tool", templateUrl: "query-tool.component.html", styleUrls: ["query-tool.component.scss"], imports: [ - RouterLink, AsyncPipe, PageScaffoldComponent, MatDividerModule, MatFormFieldModule, + RouterLink, AsyncPipe, PageScaffoldComponent, MatDividerModule, MatFormFieldModule, MatTreeModule, MatIconModule, MatInputModule, FormsModule, ReactiveFormsModule, MatButtonToggleModule, CodeEditor, ResizableDirective, - DatePipe, SpinnerComponent, MatTableModule, MatSortModule, MatTooltipModule, MatButtonModule, RichTooltipDirective, + DatePipe, SpinnerComponent, MatTableModule, MatSortModule, MatTooltipModule, MatButtonModule, RichTooltipDirective, SchemaTreeNodeComponent, DetectScrollDirective, ] }) export class QueryToolComponent implements OnInit, AfterViewInit, OnDestroy { @@ -49,8 +53,10 @@ export class QueryToolComponent implements OnInit, AfterViewInit, OnDestroy { readonly codeEditorTheme = basicDark; codeEditorHidden = true; - constructor(protected state: QueryToolState, public driver: DriverState, private appData: AppData, private snackbar: SnackbarService) { - } + constructor( + protected state: QueryToolState, public driver: DriverState, + private appData: AppData, private snackbar: SnackbarService + ) {} ngOnInit() { this.appData.viewState.setLastUsedTool("query"); @@ -67,6 +73,7 @@ export class QueryToolComponent implements OnInit, AfterViewInit, OnDestroy { ngAfterViewInit() { const articleWidth = this.articleRef.nativeElement.clientWidth; this.resizables.first.percent = (articleWidth * 0.15 + 100) / articleWidth * 100; + this.resizables.last.percent = (articleWidth * 0.15 + 100) / articleWidth * 100; this.graphViewRef.changes.pipe( map(x => x as QueryList>), startWith(this.graphViewRef), diff --git a/src/module/query/schema-tree-node/schema-tree-node.component.html b/src/module/query/schema-tree-node/schema-tree-node.component.html new file mode 100644 index 000000000..249729e45 --- /dev/null +++ b/src/module/query/schema-tree-node/schema-tree-node.component.html @@ -0,0 +1,24 @@ +@switch (data.nodeKind) { + @case ("root") { + {{ data.label }} + } + @case ("concept") { + {{ data.concept.label }}@if (data.concept.kind === "attributeType") {, value {{ data.concept.valueType }}} + } + @case ("link") { + @switch (data.linkKind) { + @case ("sub") { +

sub {{ data.supertype.label }}

+ } + @case ("owns") { +

owns {{ data.ownedAttribute.label }}, value {{ data.ownedAttribute.valueType }}

+ } + @case ("relates") { +

relates {{ data.role.label }}

+ } + @case ("plays") { +

plays {{ data.role.label }}

+ } + } + } +} diff --git a/src/module/query/schema-tree-node/schema-tree-node.component.scss b/src/module/query/schema-tree-node/schema-tree-node.component.scss new file mode 100644 index 000000000..f46dbe0d1 --- /dev/null +++ b/src/module/query/schema-tree-node/schema-tree-node.component.scss @@ -0,0 +1,5 @@ +/*!/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ diff --git a/src/module/query/schema-tree-node/schema-tree-node.component.ts b/src/module/query/schema-tree-node/schema-tree-node.component.ts new file mode 100644 index 000000000..f5e7e3d0b --- /dev/null +++ b/src/module/query/schema-tree-node/schema-tree-node.component.ts @@ -0,0 +1,33 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { Component, Input } from "@angular/core"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { MatButtonModule } from "@angular/material/button"; +import { MatButtonToggleModule } from "@angular/material/button-toggle"; +import { MatDividerModule } from "@angular/material/divider"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatIconModule } from "@angular/material/icon"; +import { MatInputModule } from "@angular/material/input"; +import { MatSortModule } from "@angular/material/sort"; +import { MatTableModule } from "@angular/material/table"; +import { MatTreeModule } from "@angular/material/tree"; +import { MatTooltipModule } from "@angular/material/tooltip"; +import { SchemaTreeNode } from "../../../service/query-tool-state.service"; + +@Component({ + selector: "ts-schema-tree-node", + templateUrl: "schema-tree-node.component.html", + styleUrls: ["schema-tree-node.component.scss"], + imports: [ + MatDividerModule, MatFormFieldModule, MatTreeModule, MatIconModule, + MatInputModule, FormsModule, ReactiveFormsModule, MatButtonToggleModule, + MatTableModule, MatSortModule, MatTooltipModule, MatButtonModule, + ] +}) +export class SchemaTreeNodeComponent { + @Input({ required: true }) data!: SchemaTreeNode; +} diff --git a/src/module/schema/schema-tool.component.scss b/src/module/schema/schema-tool.component.scss index 9dcd7f2c2..db43a3eff 100644 --- a/src/module/schema/schema-tool.component.scss +++ b/src/module/schema/schema-tool.component.scss @@ -10,14 +10,8 @@ @use "typography"; @use "shapes"; -:host { - height: 100%; -} - .schema-page { - height: 100%; - margin-top: 16px; - margin-bottom: 16px; + height: calc(100% - 86px); gap: 4px !important; ::ng-deep app-drag-handle span { diff --git a/src/service/driver-state.service.ts b/src/service/driver-state.service.ts index a7c756245..fae90dec2 100644 --- a/src/service/driver-state.service.ts +++ b/src/service/driver-state.service.ts @@ -13,7 +13,7 @@ import { ConnectionConfig, databasesSortedByName, DEFAULT_DATABASE_NAME } from " import { Transaction } from "../concept/transaction"; import { TypeDBHttpDriver } from "../framework/typedb-driver"; import { Database } from "../framework/typedb-driver/database"; -import { ApiResponse, isApiErrorResponse, isOkResponse, QueryResponse, VersionResponse } from "../framework/typedb-driver/response"; +import { ApiOkResponse, ApiResponse, isApiErrorResponse, isOkResponse, QueryResponse, VersionResponse } from "../framework/typedb-driver/response"; import { requireValue } from "../framework/util/observable"; import { INTERNAL_ERROR } from "../framework/util/strings"; import { AppData } from "./app-data.service"; @@ -309,7 +309,7 @@ export class DriverState { ), lockId); } - runBackgroundReadQueries(queries: string[]): Observable> { + runBackgroundReadQueries(queries: string[]): Observable> { const driver = this.requireDriver(`${this.constructor.name}.${this.query.name} > ${this.requireDriver.name}`); const databaseName = this.requireDatabase(`${this.constructor.name}.${this.openTransaction.name} > ${this.requireDatabase.name}`).name; return fromPromise(driver.openTransaction(databaseName, "read")).pipe( diff --git a/src/service/query-tool-state.service.ts b/src/service/query-tool-state.service.ts index 1c548fe0d..42a91c744 100644 --- a/src/service/query-tool-state.service.ts +++ b/src/service/query-tool-state.service.ts @@ -17,7 +17,10 @@ import { Concept, Value } from "../framework/typedb-driver/concept"; import { ApiResponse, ConceptDocument, ConceptRow, isApiErrorResponse, QueryResponse } from "../framework/typedb-driver/response"; import { INTERNAL_ERROR } from "../framework/util/strings"; import { DriverState } from "./driver-state.service"; +import { SchemaState, Schema, SchemaAttribute, SchemaRole, SchemaConcept } from "./schema-state.service"; import { SnackbarService } from "./snackbar.service"; +import { FlatTreeControl } from "@angular/cdk/tree"; +import { MatTreeFlatDataSource, MatTreeFlattener } from "@angular/material/tree"; export type OutputType = "raw" | "log" | "table" | "graph"; @@ -34,11 +37,12 @@ export class QueryToolState { queryControl = new FormControl("", {nonNullable: true}); outputTypeControl = new FormControl("log" as OutputType, { nonNullable: true }); outputTypes: OutputType[] = ["log", "table", "graph", "raw"]; - readonly history = new HistoryWindowState(this.driver); + readonly schemaWindow = new SchemaWindowState(this.schema); readonly logOutput = new LogOutputState(); readonly tableOutput = new TableOutputState(); readonly graphOutput = new GraphOutputState(); readonly rawOutput = new RawOutputState(); + readonly history = new HistoryWindowState(this.driver); answersOutputEnabled = true; readonly runDisabledReason$ = combineLatest( [this.driver.status$, this.driver.database$, this.driver.autoTransactionEnabled$, this.driver.transaction$, this.queryControl.valueChanges.pipe(startWith(this.queryControl.value))] @@ -53,7 +57,7 @@ export class QueryToolState { readonly outputDisabledReason$ = this.driver.status$.pipe(map(x => x === "connected" ? null : NO_SERVER_CONNECTED)); readonly outputDisabled$ = this.outputDisabledReason$.pipe(map(x => x != null)); - constructor(private driver: DriverState, private snackbar: SnackbarService) { + constructor(private driver: DriverState, private schema: SchemaState, private snackbar: SnackbarService) { (window as any)["queryToolState"] = this; this.outputDisabled$.subscribe((disabled) => { if (disabled) this.outputTypeControl.disable(); @@ -131,6 +135,113 @@ export class QueryToolState { } } +export type SchemaTreeNodeKind = "root" | "concept" | "link"; + +export interface SchemaTreeNodeBase { + nodeKind: SchemaTreeNodeKind; + children?: SchemaTreeNode[]; +} + +export interface SchemaTreeRootNode extends SchemaTreeNodeBase { + nodeKind: "root"; + label: string; + children: SchemaTreeConceptNode[]; +} + +export interface SchemaTreeConceptNode extends SchemaTreeNodeBase { + nodeKind: "concept"; + concept: SchemaConcept; + children: SchemaTreeLinkNode[]; +} + +export type SchemaTreeLinkKind = "sub" | "owns" | "plays" | "relates"; + +export interface SchemaTreeLinkNodeBase extends SchemaTreeNodeBase { + nodeKind: "link"; + linkKind: SchemaTreeLinkKind; +} + +export interface SchemaTreeSubLinkNode extends SchemaTreeLinkNodeBase { + linkKind: "sub"; + supertype: SchemaConcept; +} + +export interface SchemaTreeOwnsLinkNode extends SchemaTreeLinkNodeBase { + linkKind: "owns"; + ownedAttribute: SchemaAttribute; +} + +export interface SchemaTreePlaysLinkNode extends SchemaTreeLinkNodeBase { + linkKind: "plays"; + role: SchemaRole; +} + +export interface SchemaTreeRelatesLinkNode extends SchemaTreeLinkNodeBase { + linkKind: "relates"; + role: SchemaRole; +} + +export type SchemaTreeLinkNode = SchemaTreeSubLinkNode | SchemaTreeOwnsLinkNode | SchemaTreePlaysLinkNode | SchemaTreeRelatesLinkNode; + +export type SchemaTreeNode = SchemaTreeRootNode | SchemaTreeConceptNode | SchemaTreeLinkNode; + +export class SchemaWindowState { + dataSource: SchemaTreeRootNode[] = []; + + constructor(public schemaState: SchemaState) { + schemaState.value$.subscribe(schema => { + this.populateDataSources(schema); + }); + } + + hasChild = (_: number, node: SchemaTreeNode) => !!node.children?.length; + + childrenAccessor = (node: SchemaTreeNode) => node.children ?? []; + + private populateDataSources(schema: Schema | null) { + if (!schema) { + this.dataSource.length = 0; + return; + } + this.dataSource = [{ + nodeKind: "root", + label: "Entities", + children: Object.values(schema.entities).sort((a, b) => a.label.localeCompare(b.label)).map(x => ({ + nodeKind: "concept", + concept: x, + children: ([ + ...(x.supertype ? [{ nodeKind: "link", linkKind: "sub", supertype: x.supertype }] : []), + ...x.ownedAttributes.map(y => ({ nodeKind: "link", linkKind: "owns", ownedAttribute: y })), + ...x.playedRoles.map(y => ({ nodeKind: "link", linkKind: "plays", role: y })), + ] as SchemaTreeLinkNode[]), + })), + }, { + nodeKind: "root", + label: "Relations", + children: Object.values(schema.relations).sort((a, b) => a.label.localeCompare(b.label)).map(x => ({ + nodeKind: "concept", + concept: x, + children: ([ + ...(x.supertype ? [{ nodeKind: "link", linkKind: "sub", supertype: x.supertype }] : []), + ...x.relatedRoles.map(y => ({ nodeKind: "link", linkKind: "relates", role: y })), + ...x.ownedAttributes.map(y => ({ nodeKind: "link", linkKind: "owns", ownedAttribute: y })), + ...x.playedRoles.map(y => ({ nodeKind: "link", linkKind: "plays", role: y })), + ] as SchemaTreeLinkNode[]), + })), + }, { + nodeKind: "root", + label: "Attributes", + children: Object.values(schema.attributes).sort((a, b) => a.label.localeCompare(b.label)).map(x => ({ + nodeKind: "concept", + concept: x, + children: ([ + ...(x.supertype ? [{ nodeKind: "link", linkKind: "sub", supertype: x.supertype }] : []), + ] as SchemaTreeLinkNode[]), + })), + }]; + } +} + export class HistoryWindowState { readonly entries: DriverAction[] = []; @@ -500,603 +611,3 @@ export class RawOutputState { this.control.patchValue(``); } } - -/* -class QueryRunner constructor( - val transactionState: TransactionState, // TODO: restrict in the future, when TypeDB 3.0 answers return complete info - private val notificationSrv: NotificationService, - private val preferenceSrv: PreferenceService, - private val queries: String, - private val onComplete: () -> Unit -) { - - sealed class Response { - - object Done : Response() - - data class Message(val type: Type, val text: String) : Response() { - enum class Type { INFO, SUCCESS, ERROR, TYPEQL } - } - - data class Value(val value: com.vaticle.typedb.driver.api.concept.value.Value?) : Response() - - sealed class Stream : Response() { - - val queue = LinkedBlockingQueue>() - - class ConceptMapGroups : Stream() - class ValueGroups : Stream() - class JSONs : Stream() - class ConceptMaps constructor(val source: Source) : Stream() { - enum class Source { INSERT, UPDATE, GET } - } - } -} - - companion object { - const val RESULT_ = "## Result> " - const val ERROR_ = "## Error> " - const val RUNNING_ = "## Running> " - const val COMPLETED = "## Completed" - const val TERMINATED = "## Terminated" - const val DEFINE_QUERY = "Define query:" - const val DEFINE_QUERY_SUCCESS = "Define query successfully defined new types in the schema." - const val UNDEFINE_QUERY = "Undefine query:" - const val UNDEFINE_QUERY_SUCCESS = "Undefine query successfully undefined types in the schema." - const val DELETE_QUERY = "Delete query:" - const val DELETE_QUERY_SUCCESS = "Delete query successfully deleted things from the database." - const val INSERT_QUERY = "Insert query:" - const val INSERT_QUERY_SUCCESS = "Insert query successfully inserted new things to the database:" - const val INSERT_QUERY_NO_RESULT = "Insert query did not insert any new thing to the database." - const val UPDATE_QUERY = "Update query:" - const val UPDATE_QUERY_SUCCESS = "Update query successfully updated things in the databases:" - const val UPDATE_QUERY_NO_RESULT = "Update query did not update any thing in the databases." - const val GET_QUERY = "Get query:" - const val GET_QUERY_SUCCESS = "Get query successfully matched concepts in the database:" - const val GET_QUERY_NO_RESULT = "Get query did not match any concepts in the database." - const val GET_AGGREGATE_QUERY = "Get Aggregate query:" - const val GET_AGGREGATE_QUERY_SUCCESS = "Get Aggregate query successfully calculated:" - const val GET_GROUP_QUERY = "Get Group query:" - const val GET_GROUP_QUERY_SUCCESS = "Get Group query successfully matched concept groups in the database:" - const val GET_GROUP_QUERY_NO_RESULT = "Get Group query did not match any concept groups in the database." - const val GET_GROUP_AGGREGATE_QUERY = "Get Group Aggregate query:" - const val GET_GROUP_AGGREGATE_QUERY_SUCCESS = - "Get Group Aggregate query successfully aggregated matched concept groups in the database:" - const val GET_GROUP_AGGREGATE_QUERY_NO_RESULT = - "Get Group Aggregate query did not match any concept groups to aggregate in the database." - const val FETCH_QUERY = "Fetch query:" - const val FETCH_QUERY_SUCCESS = "Fetch query successfully retrieved data from the database:" - const val FETCH_QUERY_NO_RESULT = "Fetch query did not retrieve any data from the database." - - private const val COUNT_DOWN_LATCH_PERIOD_MS: Long = 50 - private val LOGGER = KotlinLogging.logger {} - } - - var startTime: Long? = null - var endTime: Long? = null - val responses = LinkedBlockingQueue() - val isConsumed: Boolean get() = consumerLatch.count == 0L - val isRunning = AtomicBoolean(false) -private val consumerLatch = CountDownLatch(1) -private val coroutines = CoroutineScope(Dispatchers.Default) -private val hasStopSignal get() = transactionState.hasStopSignalAtomic -private val transaction get() = transactionState.transaction!! -private val onClose = LinkedBlockingQueue<() -> Unit>() - - fun onClose(function: () -> Unit) = onClose.put(function) - - fun setConsumed() = consumerLatch.countDown() - -private fun collectEmptyLine() = collectMessage(INFO, "") - -private fun collectMessage(type: Response.Message.Type, string: String) { - responses.put(Response.Message(type, string)) - } - - internal fun launch() = coroutines.launchAndHandle(notificationSrv, LOGGER) { - try { - isRunning.set(true) - startTime = System.currentTimeMillis() - runQueries(TypeQL.parseQueries(queries).toList()) - } catch (e: Exception) { - collectEmptyLine() - collectMessage(ERROR, ERROR_ + e.message) - } finally { - endTime = System.currentTimeMillis() - isRunning.set(false) - responses.add(Response.Done) - var isConsumed: Boolean - if (!hasStopSignal.state) { - do { - isConsumed = consumerLatch.count == 0L - if (!isConsumed) delay(COUNT_DOWN_LATCH_PERIOD_MS) - } while (!isConsumed && !hasStopSignal.state) - } - onComplete() - } - } - -private fun runQueries(queries: List) = queries.forEach { query -> - if (hasStopSignal.state) return@forEach - when (query) { - is TypeQLDefine -> runDefineQuery(query) - is TypeQLUndefine -> runUndefineQuery(query) - is TypeQLDelete -> runDeleteQuery(query) - is TypeQLInsert -> runInsertQuery(query) - is TypeQLUpdate -> runUpdateQuery(query) - is TypeQLGet -> runGetQuery(query) - is TypeQLGet.Aggregate -> runGetAggregateQuery(query) - is TypeQLGet.Group -> runGetGroupQuery(query) - is TypeQLGet.Group.Aggregate -> runGetGroupAggregateQuery(query) - is TypeQLFetch -> runFetchQuery(query) - else -> throw IllegalStateException("Unrecognised TypeQL query") - } - } - -private fun runDefineQuery(query: TypeQLDefine) = runUnitQuery( - name = DEFINE_QUERY, - successMsg = DEFINE_QUERY_SUCCESS, - queryStr = query.toString() - ) { transaction.query().define(query).resolve() } - -private fun runUndefineQuery(query: TypeQLUndefine) = runUnitQuery( - name = UNDEFINE_QUERY, - successMsg = UNDEFINE_QUERY_SUCCESS, - queryStr = query.toString() - ) { transaction.query().undefine(query).resolve() } - -private fun runDeleteQuery(query: TypeQLDelete) = runUnitQuery( - name = DELETE_QUERY, - successMsg = DELETE_QUERY_SUCCESS, - queryStr = query.toString() - ) { transaction.query().delete(query).resolve() } - -private fun runInsertQuery(query: TypeQLInsert) = runStreamingQuery( - name = INSERT_QUERY, - successMsg = INSERT_QUERY_SUCCESS, - noResultMsg = INSERT_QUERY_NO_RESULT, - queryStr = query.toString(), - stream = Response.Stream.ConceptMaps(INSERT) - ) { transaction.query().insert(query, transactionState.defaultTypeDBOptions().prefetch(true)) } - -private fun runUpdateQuery(query: TypeQLUpdate) = runStreamingQuery( - name = UPDATE_QUERY, - successMsg = UPDATE_QUERY_SUCCESS, - noResultMsg = UPDATE_QUERY_NO_RESULT, - queryStr = query.toString(), - stream = Response.Stream.ConceptMaps(UPDATE) - ) { transaction.query().update(query, transactionState.defaultTypeDBOptions().prefetch(true)) } - -private fun runGetQuery(query: TypeQLGet) = runStreamingQuery( - name = GET_QUERY, - successMsg = GET_QUERY_SUCCESS, - noResultMsg = GET_QUERY_NO_RESULT, - queryStr = query.toString(), - stream = Response.Stream.ConceptMaps(GET) - ) { - if (query.modifiers().limit().isPresent) { - transaction.query().get(query) - } else { - val queryWithLimit = TypeQLGet.Limited(query, preferenceSrv.getQueryLimit) - transaction.query().get(queryWithLimit) - } - } - -private fun runGetAggregateQuery(query: TypeQLGet.Aggregate) { - printQueryStart(GET_AGGREGATE_QUERY, query.toString()) - val result = transaction.query().get(query).resolve().orElse(null) - collectEmptyLine() - collectMessage(SUCCESS, RESULT_ + GET_AGGREGATE_QUERY_SUCCESS) - responses.put(Response.Value(result)) - } - -private fun runGetGroupQuery(query: TypeQLGet.Group) = runStreamingQuery( - name = GET_GROUP_QUERY, - successMsg = GET_GROUP_QUERY_SUCCESS, - noResultMsg = GET_GROUP_QUERY_NO_RESULT, - queryStr = query.toString(), - stream = Response.Stream.ConceptMapGroups() - ) { transaction.query().get(query) } - -private fun runGetGroupAggregateQuery(query: TypeQLGet.Group.Aggregate) = runStreamingQuery( - name = GET_GROUP_AGGREGATE_QUERY, - successMsg = GET_GROUP_AGGREGATE_QUERY_SUCCESS, - noResultMsg = GET_GROUP_AGGREGATE_QUERY_NO_RESULT, - queryStr = query.toString(), - stream = Response.Stream.ValueGroups() - ) { transaction.query().get(query) } - -private fun runFetchQuery(query: TypeQLFetch) = runStreamingQuery( - name = FETCH_QUERY, - successMsg = FETCH_QUERY_SUCCESS, - noResultMsg = FETCH_QUERY_NO_RESULT, - queryStr = query.toString(), - stream = Response.Stream.JSONs() - ) { - if (query.modifiers().limit().isPresent) { - transaction.query().fetch(query) - } else { - val queryWithLimit = TypeQLFetch.Limited(query, preferenceSrv.getQueryLimit) - transaction.query().fetch(queryWithLimit) - } - } - -private fun runUnitQuery(name: String, successMsg: String, queryStr: String, queryFn: () -> Unit) { - printQueryStart(name, queryStr) - queryFn() - collectEmptyLine() - collectMessage(SUCCESS, RESULT_ + successMsg) - } - -private fun runStreamingQuery( - name: String, - successMsg: String, - noResultMsg: String, - queryStr: String, - stream: Response.Stream, - queryFn: () -> Stream -) { - printQueryStart(name, queryStr) - collectResponseStream(queryFn(), successMsg, noResultMsg, stream) - } - -private fun printQueryStart(name: String, queryStr: String) { - collectEmptyLine() - collectMessage(INFO, RUNNING_ + name) - collectMessage(TYPEQL, queryStr) - } - -private fun collectResponseStream( - results: Stream, - successMsg: String, - noResultMsg: String, - stream: Response.Stream -) { - var started = false - var error = false - try { - collectEmptyLine() - results.peek { - if (started) return@peek - collectMessage(SUCCESS, RESULT_ + successMsg) - responses.put(stream) - started = true - }.forEach { - if (hasStopSignal.state) return@forEach - stream.queue.put(Either.first(it)) - } - } catch (e: Exception) { - collectMessage(ERROR, ERROR_ + e.message) - error = true - } finally { - if (started) stream.queue.put(Either.second(Response.Done)) - if (error || hasStopSignal.state) collectMessage(ERROR, TERMINATED) - else if (started) collectMessage(INFO, COMPLETED) - else collectMessage(SUCCESS, RESULT_ + noResultMsg) - } - } - - fun close() { - hasStopSignal.set(true) - onClose.forEach { it() } - } -} - */ - -/* -class QueryRunner( - val transactionState: TransactionState, // TODO: restrict in the future, when TypeDB 3.0 answers return complete info - private val notificationSrv: NotificationService, - private val preferenceSrv: PreferenceService, - private val queries: String, - private val onComplete: () -> Unit -) { - - sealed class Response { - - object Done : Response() - - data class Message(val type: Type, val text: String) : Response() { - enum class Type { INFO, SUCCESS, ERROR, TYPEQL } - } - - data class Value(val value: com.typedb.driver.api.concept.value.Value) : Response() - - sealed class Stream : Response() { - - val queue = LinkedBlockingQueue>() - - class JSONs : Stream() - class ConceptRows : Stream() - } -} - - companion object { - const val RESULT_ = "## Result> " - const val ERROR_ = "## Error> " - const val RUNNING_ = "## Running> " - const val COMPLETED = "## Completed" - const val TERMINATED = "## Terminated" - const val QUERY = "Query:" - const val QUERY_SUCCESS = "Success." - const val QUERY_NO_RESULT = "Query returned no results." - const val DEFINE_QUERY = "Define query:" - const val DEFINE_QUERY_SUCCESS = "Define query successfully defined new types in the schema." - const val UNDEFINE_QUERY = "Undefine query:" - const val UNDEFINE_QUERY_SUCCESS = "Undefine query successfully undefined types in the schema." - const val DELETE_QUERY = "Delete query:" - const val DELETE_QUERY_SUCCESS = "Delete query successfully deleted things from the database." - const val INSERT_QUERY = "Insert query:" - const val INSERT_QUERY_SUCCESS = "Insert query successfully inserted new things to the database:" - const val INSERT_QUERY_NO_RESULT = "Insert query did not insert any new thing to the database." - const val UPDATE_QUERY = "Update query:" - const val UPDATE_QUERY_SUCCESS = "Update query successfully updated things in the databases:" - const val UPDATE_QUERY_NO_RESULT = "Update query did not update any thing in the databases." - const val GET_QUERY = "Get query:" - const val GET_QUERY_SUCCESS = "Get query successfully matched concepts in the database:" - const val GET_QUERY_NO_RESULT = "Get query did not match any concepts in the database." - const val GET_AGGREGATE_QUERY = "Get Aggregate query:" - const val GET_AGGREGATE_QUERY_SUCCESS = "Get Aggregate query successfully calculated:" - const val GET_GROUP_QUERY = "Get Group query:" - const val GET_GROUP_QUERY_SUCCESS = "Get Group query successfully matched concept groups in the database:" - const val GET_GROUP_QUERY_NO_RESULT = "Get Group query did not match any concept groups in the database." - const val GET_GROUP_AGGREGATE_QUERY = "Get Group Aggregate query:" - const val GET_GROUP_AGGREGATE_QUERY_SUCCESS = - "Get Group Aggregate query successfully aggregated matched concept groups in the database:" - const val GET_GROUP_AGGREGATE_QUERY_NO_RESULT = - "Get Group Aggregate query did not match any concept groups to aggregate in the database." - const val FETCH_QUERY = "Fetch query:" - const val FETCH_QUERY_SUCCESS = "Fetch query successfully retrieved data from the database:" - const val FETCH_QUERY_NO_RESULT = "Fetch query did not retrieve any data from the database." - - private const val COUNT_DOWN_LATCH_PERIOD_MS: Long = 50 - private val LOGGER = KotlinLogging.logger {} - } - - var startTime: Long? = null - var endTime: Long? = null - val responses = LinkedBlockingQueue() - val isConsumed: Boolean get() = consumerLatch.count == 0L - val isRunning = AtomicBoolean(false) -private val consumerLatch = CountDownLatch(1) -private val coroutines = CoroutineScope(Dispatchers.Default) -private val hasStopSignal get() = transactionState.hasStopSignal -private val transaction get() = transactionState.transaction!! -private val onClose = LinkedBlockingQueue<() -> Unit>() - - fun onClose(function: () -> Unit) = onClose.put(function) - - fun setConsumed() = consumerLatch.countDown() - -private fun collectEmptyLine() = collectMessage(INFO, "") - -private fun collectMessage(type: Response.Message.Type, string: String) { - responses.put(Response.Message(type, string)) - } - - internal fun launch() = coroutines.launchAndHandle(notificationSrv, LOGGER) { - try { - isRunning.set(true) - startTime = System.currentTimeMillis() - runQuery(queries) -// runQueries(TypeQL.parseQueries(queries).collect(Collectors.toList())) - } catch (e: Exception) { - collectEmptyLine() - collectMessage(ERROR, ERROR_ + e.message) - } finally { - endTime = System.currentTimeMillis() - isRunning.set(false) - responses.add(Response.Done) - var isConsumed: Boolean - if (!hasStopSignal) { - do { - isConsumed = consumerLatch.count == 0L - if (!isConsumed) delay(COUNT_DOWN_LATCH_PERIOD_MS) - } while (!isConsumed && !hasStopSignal) - } - onComplete() - } - } - -private fun runQuery(query: String) { - if (hasStopSignal) return - - collectEmptyLine() - collectMessage(INFO, RUNNING_) - collectMessage(TYPEQL, query) - - val answer = transaction.query(query).resolve() - - if (answer.isOk) { - collectEmptyLine() - collectMessage(SUCCESS, RESULT_ + QUERY_SUCCESS) - return - } else if (answer.isConceptRows) { - val streamRaw = answer.asConceptRows().stream() - val stream = Response.Stream.ConceptRows() - - var started = false - var error = false - try { - collectEmptyLine() - streamRaw.peek { - if (started) return@peek - collectMessage(SUCCESS, RESULT_ + QUERY_SUCCESS) - responses.put(stream) - started = true - }.forEach { - if (hasStopSignal) return@forEach - stream.queue.put(Either.first(it)) - } - } catch (e: Exception) { - collectMessage(ERROR, ERROR_ + e.message) - error = true - } finally { - if (started) stream.queue.put(Either.second(Response.Done)) - if (error || hasStopSignal) collectMessage(ERROR, TERMINATED) - else if (started) collectMessage(INFO, COMPLETED) - else collectMessage(SUCCESS, RESULT_ + QUERY_NO_RESULT) - } - } else if (answer.isConceptDocuments) { - val streamRaw = answer.asConceptDocuments().stream() - val stream = Response.Stream.JSONs() - - var started = false - var error = false - try { - collectEmptyLine() - streamRaw.peek { - if (started) return@peek - collectMessage(SUCCESS, RESULT_ + QUERY_SUCCESS) - responses.put(stream) - started = true - }.forEach { - if (hasStopSignal) return@forEach - stream.queue.put(Either.first(it)) - } - } catch (e: Exception) { - collectMessage(ERROR, ERROR_ + e.message) - error = true - } finally { - if (started) stream.queue.put(Either.second(Response.Done)) - if (error || hasStopSignal) collectMessage(ERROR, TERMINATED) - else if (started) collectMessage(INFO, COMPLETED) - else collectMessage(SUCCESS, RESULT_ + QUERY_SUCCESS) - } - } else throw IllegalArgumentException() - } - -private fun runDefineQuery(query: TypeQLDefine) = runUnitQuery( - name = DEFINE_QUERY, - successMsg = DEFINE_QUERY_SUCCESS, - queryStr = query.toString() - ) { transaction.query(query.toString()).resolve() } - -private fun runUndefineQuery(query: TypeQLUndefine) = runUnitQuery( - name = UNDEFINE_QUERY, - successMsg = UNDEFINE_QUERY_SUCCESS, - queryStr = query.toString() - ) { transaction.query(query.toString()).resolve() } - -private fun runDeleteQuery(query: TypeQLDelete) = runUnitQuery( - name = DELETE_QUERY, - successMsg = DELETE_QUERY_SUCCESS, - queryStr = query.toString() - ) { transaction.query(query.toString()).resolve() } - -private fun runInsertQuery(query: TypeQLInsert) = runStreamingQuery( - name = INSERT_QUERY, - successMsg = INSERT_QUERY_SUCCESS, - noResultMsg = INSERT_QUERY_NO_RESULT, - queryStr = query.toString(), - stream = Response.Stream.ConceptRows() - ) { transaction.query(query.toString()).resolve().asConceptRows().stream() } // TODO: prefetch = true option - -private fun runUpdateQuery(query: TypeQLUpdate) = runStreamingQuery( - name = UPDATE_QUERY, - successMsg = UPDATE_QUERY_SUCCESS, - noResultMsg = UPDATE_QUERY_NO_RESULT, - queryStr = query.toString(), - stream = Response.Stream.ConceptRows() - ) { transaction.query(query.toString()).resolve().asConceptRows().stream() } - -private fun runGetQuery(query: TypeQLGet) = runStreamingQuery( - name = GET_QUERY, - successMsg = GET_QUERY_SUCCESS, - noResultMsg = GET_QUERY_NO_RESULT, - queryStr = query.toString(), - stream = Response.Stream.ConceptRows() - ) { -// if (query.modifiers().limit().isPresent) { - transaction.query(query.toString()).resolve().asConceptRows().stream() -// } else { -// val queryWithLimit = TypeQLGet.Limited(query, preferenceSrv.getQueryLimit) -// transaction.query().get(queryWithLimit) -// } - } - -private fun runGetAggregateQuery(query: TypeQLGet.Aggregate) { - collectMessage(INFO, "runGetAggregateQuery: unsupported") -// printQueryStart(GET_AGGREGATE_QUERY, query.toString()) -// val result = transaction.query(query.toString()).resolve() -// collectEmptyLine() -// collectMessage(SUCCESS, RESULT_ + GET_AGGREGATE_QUERY_SUCCESS) -// responses.put(Response.Value(result)) - } - -private fun runFetchQuery(query: TypeQLFetch) = runStreamingQuery( - name = FETCH_QUERY, - successMsg = FETCH_QUERY_SUCCESS, - noResultMsg = FETCH_QUERY_NO_RESULT, - queryStr = query.toString(), - stream = Response.Stream.JSONs() - ) { -// if (query.modifiers().limit().isPresent) { - transaction.query(query.toString()).resolve().asConceptDocuments().stream() -// } else { -// val queryWithLimit = TypeQLFetch.Limited(query, preferenceSrv.getQueryLimit) -// transaction.query().fetch(queryWithLimit) -// } - } - -private fun runUnitQuery(name: String, successMsg: String, queryStr: String, queryFn: () -> Unit) { - printQueryStart(name, queryStr) - queryFn() - collectEmptyLine() - collectMessage(SUCCESS, RESULT_ + successMsg) - } - -private fun runStreamingQuery( - name: String, - successMsg: String, - noResultMsg: String, - queryStr: String, - stream: Response.Stream, - queryFn: () -> Stream -) { - printQueryStart(name, queryStr) - collectResponseStream(queryFn(), successMsg, noResultMsg, stream) - } - -private fun printQueryStart(name: String, queryStr: String) { - collectEmptyLine() - collectMessage(INFO, RUNNING_ + name) - collectMessage(TYPEQL, queryStr) - } - -private fun collectResponseStream( - results: Stream, - successMsg: String, - noResultMsg: String, - stream: Response.Stream -) { - var started = false - var error = false - try { - collectEmptyLine() - results.peek { - if (started) return@peek - collectMessage(SUCCESS, RESULT_ + successMsg) - responses.put(stream) - started = true - }.forEach { - if (hasStopSignal) return@forEach - stream.queue.put(Either.first(it)) - } - } catch (e: Exception) { - collectMessage(ERROR, ERROR_ + e.message) - error = true - } finally { - if (started) stream.queue.put(Either.second(Response.Done)) - if (error || hasStopSignal) collectMessage(ERROR, TERMINATED) - else if (started) collectMessage(INFO, COMPLETED) - else collectMessage(SUCCESS, RESULT_ + noResultMsg) - } - } - - fun close() { - transactionState.sendStopSignal() - onClose.forEach { it() } - } -} - */ diff --git a/src/service/schema-state.service.ts b/src/service/schema-state.service.ts index 52db2e8ae..ad17e5b40 100644 --- a/src/service/schema-state.service.ts +++ b/src/service/schema-state.service.ts @@ -12,7 +12,8 @@ import { createSigmaRenderer, GraphVisualiser } from "../framework/graph-visuali import { defaultSigmaSettings } from "../framework/graph-visualiser/defaults"; import { newVisualGraph } from "../framework/graph-visualiser/graph"; import { Layouts } from "../framework/graph-visualiser/layouts"; -import { ApiResponse, isApiErrorResponse, QueryResponse } from "../framework/typedb-driver/response"; +import { AttributeType, EntityType, RelationType, RoleType, Type } from "../framework/typedb-driver/concept"; +import { ApiOkResponse, ApiResponse, ConceptRowsQueryResponse, isApiErrorResponse, QueryResponse } from "../framework/typedb-driver/response"; import { DriverState } from "./driver-state.service"; import { SnackbarService } from "./snackbar.service"; @@ -22,20 +23,51 @@ const NO_DATABASE_SELECTED = `No database selected`; const schemaQueries = { typeHierarchy: `match { $t sub! $supertype; } or {$t sub $supertype; $t is $supertype; };`, ownedAttributes: `match { $t owns $attr; not { $t sub! $sown; $sown owns $attr; }; };`, - roleplayers: `match { $t relates $related; not { $t sub! $srel; $srel relates $related; }; };`, - playableRoles: `match { $t plays $played; not { $t sub! $splay; $splay plays $played; }; };`, + relatedRoles: `match { $t relates $related; not { $t sub! $srel; $srel relates $related; }; };`, + playedRoles: `match { $t plays $played; not { $t sub! $splay; $splay plays $played; }; };`, } as const satisfies Record; const schemaQueriesList = Object.values(schemaQueries); type VisualiserStatus = "ok" | "running" | "noAnswers" | "error"; +interface SchemaEntity extends EntityType { + supertype?: SchemaEntity; + subtypes: SchemaEntity[]; + ownedAttributes: SchemaAttribute[]; + playedRoles: SchemaRole[]; +} + +interface SchemaRelation extends RelationType { + supertype?: SchemaRelation; + subtypes: SchemaRelation[]; + ownedAttributes: SchemaAttribute[]; + playedRoles: SchemaRole[]; + relatedRoles: SchemaRole[]; +} + +export interface SchemaAttribute extends AttributeType { + supertype?: SchemaAttribute; + subtypes: SchemaAttribute[]; +} + +export type SchemaConcept = SchemaEntity | SchemaRelation | SchemaAttribute; + +export type SchemaRole = RoleType; + +export interface Schema { + entities: Record; + relations: Record; + attributes: Record; +} + @Injectable({ providedIn: "root", }) export class SchemaState { readonly visualiser = new VisualiserState(); - queryResponses$ = new BehaviorSubject[] | null>(null); + queryResponses$ = new BehaviorSubject[] | null>(null); + readonly value$ = new BehaviorSubject(null); isRefreshing = false; readonly refreshDisabledReason$ = combineLatest([this.driver.status$, this.driver.database$]).pipe(map(([status, db]) => { if (status !== "connected") return NO_SERVER_CONNECTED; @@ -45,12 +77,15 @@ export class SchemaState { readonly refreshEnabled$ = this.refreshDisabledReason$.pipe(map(x => x == null)); constructor(private driver: DriverState, private snackbar: SnackbarService) { - (window as any)["schemaToolState"] = this; + (window as any)["schemaState"] = this; this.driver.database$.pipe( distinctUntilChanged((x, y) => x?.name === y?.name) ).subscribe(() => { this.refresh(); }); + this.queryResponses$.subscribe(data => { + this.push(data); + }); } refresh() { @@ -67,18 +102,32 @@ export class SchemaState { } this.initialiseOutput(); - const responses: ApiResponse[] = []; + const responses: ApiOkResponse[] = []; this.isRefreshing = true; this.driver.runBackgroundReadQueries(schemaQueriesList).pipe( finalize(() => { this.isRefreshing = false; }) ).subscribe({ - next: (res) => { responses.push(res); }, + next: (res) => { + if (res.ok.answerType !== `conceptRows`) throw `Unexpected answerType: '${res.ok.answerType}' (expected 'conceptRows')`; + responses.push(res as ApiOkResponse); + }, error: (err) => { this.handleQueryError(err); }, complete: () => { this.queryResponses$.next(responses); }, }); }); } + push(data: ApiOkResponse[] | null) { + if (!data) { + this.value$.next(null); + return; + } + + const schemaBuilder = new SchemaBuilder(data); + const schema = schemaBuilder.build(); + this.value$.next(schema); + } + private initialiseOutput() { this.visualiser.destroy(); this.visualiser.status = "running"; @@ -113,6 +162,224 @@ export class SchemaState { } } +function entityOf(entityType: EntityType): SchemaEntity { + return { + kind: entityType.kind, + label: entityType.label, + supertype: undefined, + subtypes: [], + ownedAttributes: [], + playedRoles: [], + }; +} + +function relationOf(relationType: RelationType): SchemaRelation { + return { + kind: relationType.kind, + label: relationType.label, + supertype: undefined, + subtypes: [], + ownedAttributes: [], + playedRoles: [], + relatedRoles: [], + }; +} + +function attributeOf(attributeType: AttributeType): SchemaAttribute { + return { + kind: attributeType.kind, + label: attributeType.label, + supertype: undefined, + subtypes: [], + valueType: attributeType.valueType, + }; +} + +class SchemaBuilder { + readonly typeHierarchy: ConceptRowsQueryResponse; + readonly ownedAttributes: ConceptRowsQueryResponse; + readonly relatedRoles: ConceptRowsQueryResponse; + readonly playedRoles: ConceptRowsQueryResponse; + readonly entityTypes = {} as Record; + readonly relationTypes = {} as Record; + readonly attributeTypes = {} as Record; + + constructor(data: ApiOkResponse[]) { + const [typeHierarchy, ownedAttributes, relatedRoles, playedRoles] = data.map(x => x.ok); + this.typeHierarchy = typeHierarchy; + this.ownedAttributes = ownedAttributes; + this.relatedRoles = relatedRoles; + this.playedRoles = playedRoles; + } + + build(): Schema { + this.populateConcepts(); + this.buildTypeHierarchy(); + this.attachOwnedAttributes(); + this.attachPlayedRoles(); + this.attachRelatedRoles(); + return { + entities: this.entityTypes, + relations: this.relationTypes, + attributes: this.attributeTypes, + }; + } + + private populateConcepts() { + for (const answer of this.typeHierarchy.answers) { + const [type, supertype] = [answer.data["t"], answer.data["supertype"]]; + if (!type || !supertype) throw this.unexpectedTypeHierarchyAnswer(answer); + switch (type.kind) { + case "entityType": + this.entityTypes[type.label] = this.entityTypes[type.label] ?? entityOf(type); + break; + case "relationType": + this.relationTypes[type.label] = this.relationTypes[type.label] ?? relationOf(type); + break; + case "attributeType": + this.attributeTypes[type.label] = this.attributeTypes[type.label] ?? attributeOf(type); + break; + case "roleType": + continue; + default: + throw this.unexpectedTypeHierarchyAnswer(answer); + } + } + } + + private buildTypeHierarchy() { + for (const answer of this.typeHierarchy.answers) { + const [type, supertype] = [answer.data["t"], answer.data["supertype"]] as Type[]; + if (type.label === supertype.label) continue; + let node: SchemaConcept; + let supernode: SchemaConcept; + switch (type.kind) { + case "entityType": + node = this.expectEntityType(type.label); + supernode = this.expectEntityType(supertype.label); + break; + case "relationType": + node = this.expectRelationType(type.label); + supernode = this.expectRelationType(supertype.label); + break; + case "attributeType": + node = this.expectAttributeType(type.label); + supernode = this.expectAttributeType(supertype.label); + break; + case "roleType": + continue; + default: + throw this.unexpectedTypeHierarchyAnswer(answer); + } + node.supertype = supernode; + (supernode.subtypes as SchemaConcept[]).push(node); + } + } + + private attachOwnedAttributes() { + for (const answer of this.ownedAttributes.answers) { + const [ownerType, ownedAttr] = [answer.data["t"], answer.data["attr"]]; + if (!ownerType || !ownedAttr || ownedAttr.kind !== "attributeType") throw this.unexpectedOwnedAttributesAnswer(answer); + let ownerNode: SchemaConcept; + const ownedAttrNode: SchemaAttribute = this.expectAttributeType(ownedAttr.label); + switch (ownerType.kind) { + case "entityType": + ownerNode = this.expectEntityType(ownerType.label); + break; + case "relationType": + ownerNode = this.expectRelationType(ownerType.label); + break; + default: + throw this.unexpectedOwnedAttributesAnswer(answer); + } + this.propagateOwnedAttributes(ownerNode, ownedAttrNode); + } + } + + private propagateOwnedAttributes(ownerNode: SchemaEntity | SchemaRelation, ownedAttrNode: SchemaAttribute) { + ownerNode.ownedAttributes.push(ownedAttrNode); + for (const ownerSubnode of ownerNode.subtypes) { + this.propagateOwnedAttributes(ownerSubnode, ownedAttrNode); + } + } + + private attachRelatedRoles() { + for (const answer of this.relatedRoles.answers) { + const [rel, role] = [answer.data["t"], answer.data["related"]]; + if (!rel || !role || rel.kind !== "relationType" || role.kind !== "roleType") throw this.unexpectedRoleplayersAnswer(answer); + const relNode: SchemaRelation = this.expectRelationType(rel.label); + this.propagateRelatedRoles(relNode, role); + } + } + + private propagateRelatedRoles(relNode: SchemaRelation, role: RoleType) { + relNode.relatedRoles.push(role); + for (const relSubnode of relNode.subtypes) { + this.propagateRelatedRoles(relSubnode, role); + } + } + + private attachPlayedRoles() { + for (const answer of this.playedRoles.answers) { + const [obj, role] = [answer.data["t"], answer.data["played"]]; + if (!obj || !role || role.kind !== "roleType") throw this.unexpectedPlayedRolesAnswer(answer); + let objNode: SchemaEntity | SchemaRelation; + switch (obj.kind) { + case "entityType": + objNode = this.expectEntityType(obj.label); + break; + case "relationType": + objNode = this.expectRelationType(obj.label); + break; + default: + throw this.unexpectedPlayedRolesAnswer(answer); + } + this.propagatePlayedRoles(objNode, role); + } + } + + private propagatePlayedRoles(objNode: SchemaEntity | SchemaRelation, role: RoleType) { + objNode.playedRoles.push(role); + for (const objSubnode of objNode.subtypes) { + this.propagatePlayedRoles(objSubnode, role); + } + } + + private expectEntityType(label: string): SchemaEntity { + const type = this.entityTypes[label]; + if (!type) throw `Missing expected entity type in schema with label '${label}'`; + return type; + } + + private expectRelationType(label: string): SchemaRelation { + const type = this.relationTypes[label]; + if (!type) throw `Missing expected relation type in schema with label '${label}'`; + return type; + } + + private expectAttributeType(label: string): SchemaAttribute { + const type = this.attributeTypes[label]; + if (!type) throw `Missing expected attribute type in schema with label '${label}'`; + return type; + } + + private unexpectedTypeHierarchyAnswer(answer: ConceptRowsQueryResponse["answers"][number]) { + return `Unexpected type hierarchy answer: ${JSON.stringify(answer.data)}`; + } + + private unexpectedOwnedAttributesAnswer(answer: ConceptRowsQueryResponse["answers"][number]) { + return `Unexpected owned attributes answer: ${JSON.stringify(answer.data)}`; + } + + private unexpectedPlayedRolesAnswer(answer: ConceptRowsQueryResponse["answers"][number]) { + return `Unexpected played roles answer: ${JSON.stringify(answer.data)}`; + } + + private unexpectedRoleplayersAnswer(answer: ConceptRowsQueryResponse["answers"][number]) { + return `Unexpected related roles answer: ${JSON.stringify(answer.data)}`; + } +} + export class VisualiserState { status: VisualiserStatus = "ok"; diff --git a/styles/base.scss b/styles/base.scss index fc8dc302c..abf16247a 100644 --- a/styles/base.scss +++ b/styles/base.scss @@ -509,7 +509,7 @@ button i + span, button span + i { .action-bar { flex: 0 0 49px; display: flex; - margin: 16px 0 0 0; + padding: 19px 0; position: sticky; top: 0; background-color: primary.$black-purple; diff --git a/styles/material.scss b/styles/material.scss index ddb83ccae..f45367e6f 100644 --- a/styles/material.scss +++ b/styles/material.scss @@ -809,4 +809,32 @@ body { container-color: secondary.$mid-deep-grey, container-shape: shapes.$border-radius, )); + + .mdc-tooltip { + --mdc-plain-tooltip-supporting-text-size: 14px; + --mdc-plain-tooltip-supporting-line-height: 22px; + --mdc-plain-tooltip-supporting-text-tracking: var(--body-letter-spacing); + --mdc-plain-tooltip-container-color: #{secondary.$mid-deep-grey}; + --mdc-plain-tooltip-container-shape: #{shapes.$border-radius}; + } + + .mdc-tooltip__surface { + padding: 6px 10px; + } + + /* Tree */ + @include mat.tree-overrides(( + container-background-color: transparent, + node-min-height: 30px, + node-text-size: 14px, + node-text-weight: typography.$regular, + )); + + mat-tree-node[aria-level="2"] { + padding-left: 16px !important; /* overrides inline style from Angular Material */ + } + + mat-tree-node[mattreenodetoggle] { + cursor: pointer; + } } diff --git a/styles/typography.scss b/styles/typography.scss index 665ce0ba6..19710fffd 100644 --- a/styles/typography.scss +++ b/styles/typography.scss @@ -82,7 +82,7 @@ $medium: 500; $regular: 400; $italic: 400; $light: 300; -$extra-light: 200; +$thin: 200; $letter-spacing: 0.02em; @@ -114,7 +114,7 @@ $letter-spacing: 0.02em; } @mixin light-banner { - @include font-style(150px, $extra-light, 150px, $letter-spacing); + @include font-style(150px, $thin, 150px, $letter-spacing); } @mixin subtitle {