diff --git a/.changeset/rude-toes-design.md b/.changeset/rude-toes-design.md new file mode 100644 index 000000000..e323382c5 --- /dev/null +++ b/.changeset/rude-toes-design.md @@ -0,0 +1,6 @@ +--- +"@cube-creator/shared-dimensions-api": minor +"@cube-creator/ui": minor +--- + +Hierarchies can now exist in any graph in Lindas diff --git a/.github/workflows/setup-env/action.yml b/.github/workflows/setup-env/action.yml index 450922e82..97825803a 100644 --- a/.github/workflows/setup-env/action.yml +++ b/.github/workflows/setup-env/action.yml @@ -9,12 +9,6 @@ runs: cache: "yarn" - run: yarn install --ci shell: bash - - name: Set up Docker - uses: docker-practice/actions-setup-docker@master - with: - docker_version: 20.10.23 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - name: Start site uses: tpluscode/action-setup-lando@v0.1.5 with: diff --git a/apis/shared-dimensions/bootstrap/hierarchies.ts b/apis/shared-dimensions/bootstrap/hierarchies.ts index f07ebe0e3..806b6ef63 100644 --- a/apis/shared-dimensions/bootstrap/hierarchies.ts +++ b/apis/shared-dimensions/bootstrap/hierarchies.ts @@ -4,3 +4,6 @@ import type { BootstrappedResourceFactory } from './index' export const hierarchies = (ptr: BootstrappedResourceFactory) => ptr('_hierarchies').addOut(rdf.type, md.Hierarchies) + +export const externalHierarchy = (ptr: BootstrappedResourceFactory) => + ptr('_hierarchy/proxy').addOut(rdf.type, md.HierarchyProxy) diff --git a/apis/shared-dimensions/bootstrap/index.ts b/apis/shared-dimensions/bootstrap/index.ts index c4961f3ec..1dadfdab0 100644 --- a/apis/shared-dimensions/bootstrap/index.ts +++ b/apis/shared-dimensions/bootstrap/index.ts @@ -8,7 +8,7 @@ import { store } from '../lib/store' import { terms, termSets, exportSet } from './termSetCollections' import { entrypoint } from './entrypoint' import shapes from './shapes' -import { hierarchies } from './hierarchies' +import { hierarchies, externalHierarchy } from './hierarchies' export interface BootstrappedResourceFactory { (term: string): GraphPointer @@ -21,6 +21,7 @@ const resources = [ terms(pointerFactory), termSets(pointerFactory), hierarchies(pointerFactory), + externalHierarchy(pointerFactory), exportSet(pointerFactory), entrypoint(pointerFactory, ns), ...shapes, diff --git a/apis/shared-dimensions/hydra/index.ttl b/apis/shared-dimensions/hydra/index.ttl index d55331b2a..f8551f198 100644 --- a/apis/shared-dimensions/hydra/index.ttl +++ b/apis/shared-dimensions/hydra/index.ttl @@ -292,6 +292,33 @@ md:Hierarchy ] ; . +md:HierarchyProxy + a hydra:Class ; + hydra:supportedOperation + [ + a hydra:Operation ; + hydra:method "GET" ; + code:implementedBy + [ + a code:EcmaScript ; + code:link ; + ] ; + hydra-box:variables + [ + a hydra:IriTemplate ; + hydra:template "/_hierarchy/proxy{?id}" ; + hydra:variableRepresentation hydra:ExplicitRepresentation ; + hydra:mapping + [ + a hydra:IriTemplateMapping ; + hydra:property schema:identifier ; + hydra:required true ; + hydra:variable "id" ; + ] ; + ] ; + ] ; +. + a sh:Shape . a sh:Shape . diff --git a/apis/shared-dimensions/lib/domain/hierarchies.ts b/apis/shared-dimensions/lib/domain/hierarchies.ts index 041b1b4d7..710cc568b 100644 --- a/apis/shared-dimensions/lib/domain/hierarchies.ts +++ b/apis/shared-dimensions/lib/domain/hierarchies.ts @@ -36,7 +36,7 @@ export function getHierarchies({ freetextQuery, limit, offset }: GetHierarchies) } return CONSTRUCT` - ${hierarchy} ?p ?o . + ?proxyUrl ?p ?o . ` .WHERE` { @@ -44,6 +44,8 @@ export function getHierarchies({ freetextQuery, limit, offset }: GetHierarchies) } ${hierarchy} ?p ?o . + + BIND(IRI(CONCAT("${env.MANAGED_DIMENSIONS_API_BASE}", "dimension/_hierarchy/proxy?id=", ENCODE_FOR_URI(STR(${hierarchy})))) AS ?proxyUrl) ` } diff --git a/apis/shared-dimensions/lib/handlers/hierarchy.ts b/apis/shared-dimensions/lib/handlers/hierarchy.ts index 15b93cabc..9605716d4 100644 --- a/apis/shared-dimensions/lib/handlers/hierarchy.ts +++ b/apis/shared-dimensions/lib/handlers/hierarchy.ts @@ -1,20 +1,23 @@ import type { Quad } from '@rdfjs/types' -import { dcterms, sd } from '@tpluscode/rdf-ns-builders' +import { dcterms, schema, sd } from '@tpluscode/rdf-ns-builders' import { asyncMiddleware } from 'middleware-async' import $rdf from 'rdf-ext' import env from '@cube-creator/core/env' -import { meta } from '@cube-creator/core/namespace' +import { md, meta } from '@cube-creator/core/namespace' +import onetime from 'onetime' +import { sh } from '@tpluscode/rdf-ns-builders/strict' +import { isGraphPointer, isNamedNode } from 'is-graph-pointer' +import clownface, { AnyPointer, GraphPointer } from 'clownface' +import sharedDimensionsEnv from '../env' import { ShouldRewrite } from '../middleware/canonicalRewrite' +import shapeToQuery from '../shapeToQuery' +import { loadShapes } from '../store/shapes' +import { parsingClient } from '../sparql' export const get = asyncMiddleware(async (req, res) => { - const hierarchy = await req.hydra.resource.clownface() + const hierarchy: any = await req.hydra.resource.clownface() - if (!hierarchy.out(dcterms.source).terms.length) { - hierarchy.addOut(dcterms.source, source => { - source - .addOut(sd.endpoint, $rdf.namedNode(env.PUBLIC_QUERY_ENDPOINT)) - }) - } + ensureEndpoint(hierarchy) const noRewriteRoots: ShouldRewrite = (quad: Quad) => { if (quad.predicate.equals(meta.hierarchyRoot)) { @@ -30,3 +33,42 @@ export const get = asyncMiddleware(async (req, res) => { return res.dataset(hierarchy.dataset) }) + +const loadShapesOnce = onetime(loadShapes) + +export const getExternal = asyncMiddleware(async (req, res) => { + const shape: AnyPointer = (await loadShapesOnce()).has(sh.targetClass, md.Hierarchy) + + if (!isGraphPointer(shape)) { + throw new Error('Shape not found') + } + + const queryParams = clownface({ dataset: await req.dataset!() }) + const focusNode = queryParams.out(schema.identifier) + if (!isNamedNode(focusNode)) { + throw new Error('Missing or invalid id param') + } + + const url = new URL(focusNode.value, sharedDimensionsEnv.MANAGED_DIMENSIONS_BASE).toString() + const { constructQuery } = await shapeToQuery() + const query = constructQuery(shape, { + focusNode: $rdf.namedNode(url), + }) + + const hierarchy = clownface({ + dataset: $rdf.dataset(await query.execute(parsingClient)), + }).namedNode(url) + ensureEndpoint(hierarchy) + + res.setLink(url, 'canonical') + return res.dataset(hierarchy.dataset) +}) + +function ensureEndpoint(hierarchy: GraphPointer) { + if (!hierarchy.out(dcterms.source).terms.length) { + hierarchy.addOut(dcterms.source, source => { + source + .addOut(sd.endpoint, $rdf.namedNode(env.PUBLIC_QUERY_ENDPOINT)) + }) + } +} diff --git a/e2e-tests/hierarchies/external-hierarchy.hydra b/e2e-tests/hierarchies/external-hierarchy.hydra new file mode 100644 index 000000000..335a84481 --- /dev/null +++ b/e2e-tests/hierarchies/external-hierarchy.hydra @@ -0,0 +1,23 @@ +PREFIX md: +PREFIX hydra: +PREFIX schema: +PREFIX qudt: +PREFIX meta: +PREFIX sh: +PREFIX dcterms: + +ENTRYPOINT "dimension/_hierarchy/proxy?id=http://example.com/hierarchy/de-bundesland" + +HEADERS { + x-user "john-doe" + x-permission "pipelines:write" + x-email "john@doe.tech" +} + +With Class md:Hierarchy { + Expect Property schema:name "DE - Bundesland" + Expect Property meta:nextInHierarchy { + Expect Property schema:name "Bundesland" + Expect Property sh:path + } +} diff --git a/fuseki/hierarchies.trig b/fuseki/hierarchies.trig index 39c29448a..b3a30e215 100644 --- a/fuseki/hierarchies.trig +++ b/fuseki/hierarchies.trig @@ -29,3 +29,19 @@ graph { ] ; . } + +graph { + a meta:Hierarchy, hydra:Resource, md:Hierarchy ; + schema:name "DE - Bundesland" ; + md:sharedDimension ; + meta:hierarchyRoot ; + meta:nextInHierarchy + [ + schema:name "Bundesland" ; + sh:path + [ + sh:inversePath schema:containedInPlace ; + ] ; + ] ; + . +} diff --git a/fuseki/shared-dimensions.trig b/fuseki/shared-dimensions.trig index 8fe521e22..29aacf1c9 100644 --- a/fuseki/shared-dimensions.trig +++ b/fuseki/shared-dimensions.trig @@ -37,6 +37,29 @@ graph { schema:inDefinedTermSet ; } + void:inDataset . +graph { + + a schema:DefinedTermSet, meta:SharedDimension ; + schema:name "Bundesländer"@de, "Federal states"@en ; + . + + + a schema:DefinedTerm, ; + schema:identifier "BW" ; + schema:containedInPlace ; + schema:name "Baden-Württemberg"@de, "Baden-Württemberg"@en ; + schema:inDefinedTermSet ; + . + + + a schema:DefinedTerm, ; + schema:identifier "BY" ; + schema:containedInPlace ; + schema:name "Bayern"@de, "Bavaria"@en ; + schema:inDefinedTermSet ; +} + void:inDataset . graph { @@ -107,6 +130,14 @@ graph { schema:name "Poland"@en, "Polen"@de, "Pologne"@fr, "Polonia"@it ; schema:inDefinedTermSet ; . + + + a schema:DefinedTerm ; + schema:validFrom "2021-01-20T23:59:59Z"^^xsd:dateTime ; + schema:identifier "DE" ; + schema:name "Germany"@en, "Deutschland"@de, "Allemagne"@fr, "Germania"@it ; + schema:inDefinedTermSet ; + . } void:inDataset . diff --git a/packages/core/namespace.ts b/packages/core/namespace.ts index 39ca5c27c..666a116e0 100644 --- a/packages/core/namespace.ts +++ b/packages/core/namespace.ts @@ -114,6 +114,7 @@ type SharedDimensionsTerms = 'hierarchies' | 'Hierarchies' | 'Hierarchy' | + 'HierarchyProxy' | 'Entrypoint' | 'FreeTextSearchConstraintComponent' diff --git a/ui/src/api/common.ts b/ui/src/api/common.ts index b018cba77..8076b71e7 100644 --- a/ui/src/api/common.ts +++ b/ui/src/api/common.ts @@ -4,6 +4,10 @@ import { rdf, schema } from '@tpluscode/rdf-ns-builders' import { Actions } from '@/api/mixins/ApiResource' export function findOperation (resource: RdfResource, idOrType: NamedNode): RuntimeOperation | null { + if (!resource.id.value.includes(window.APP_CONFIG.apiCoreBase)) { + return null + } + const matches = resource.operations.filter(op => op.pointer.has(rdf.type, idOrType).values.length) if (matches.length > 1) { diff --git a/ui/src/forms/plugins/dimensionMetaHierarchySynchronizer.ts b/ui/src/forms/plugins/dimensionMetaHierarchySynchronizer.ts index 799d7cf38..e4a818018 100644 --- a/ui/src/forms/plugins/dimensionMetaHierarchySynchronizer.ts +++ b/ui/src/forms/plugins/dimensionMetaHierarchySynchronizer.ts @@ -9,7 +9,7 @@ import { SetObjectParams } from '@hydrofoil/shaperone-core/models/forms/reducers import { PropertyState } from '@hydrofoil/shaperone-core/models/forms' function copyGraph (from: GraphPointer, to: GraphPointer) { - const quads = from.dataset.match(null, null, null, from.term) + const quads = from.dataset.match(null, null, null, from._context[0].graph) function replace (term: Term) { return term.equals(from.term) ? to.term : term diff --git a/ui/src/store/modules/hierarchy.ts b/ui/src/store/modules/hierarchy.ts index 5981ce71f..aa71a27e8 100644 --- a/ui/src/store/modules/hierarchy.ts +++ b/ui/src/store/modules/hierarchy.ts @@ -1,6 +1,6 @@ -import { ActionTree, MutationTree, GetterTree } from 'vuex' +import { ActionTree, GetterTree, MutationTree } from 'vuex' import { api } from '@/api' -import { RootState, Hierarchy } from '../types' +import { Hierarchy, RootState } from '../types' export interface HierarchyState { hierarchy: null | Hierarchy @@ -14,7 +14,7 @@ const getters: GetterTree = {} const actions: ActionTree = { async fetchHierarchy (context, id) { - context.commit('storeHierarchy', await api.fetchResource(id)) + context.commit('storeHierarchy', await api.fetchResource(id.replaceAll('!!', '/'))) }, reset (context) {