diff --git a/app/browse/datatable.tsx b/app/browse/datatable.tsx index e370b28e1..186524ef8 100644 --- a/app/browse/datatable.tsx +++ b/app/browse/datatable.tsx @@ -80,24 +80,16 @@ export const PreviewTable = ({ const [sortDirection, setSortDirection] = useState<"asc" | "desc">(); const formatters = useDimensionFormatters(headers); const sortedObservations = useMemo(() => { - if (sortBy === undefined) { + if (!sortBy) { return observations; } const compare = sortDirection === "asc" ? ascending : descending; - const valuesIndex = uniqueMapBy(sortBy.values, (x) => x.label); + const valuesIndex = uniqueMapBy(sortBy.values, (d) => d.label); const convert = isNumericalMeasure(sortBy) || sortBy.isNumerical - ? (d: string) => +d - : (d: string) => { - const value = valuesIndex.get(d); - - if (value?.position) { - return value.position; - } - - return d; - }; + ? (value: string) => +value + : (value: string) => valuesIndex.get(value)?.position ?? value; return [...observations].sort((a, b) => compare( @@ -111,11 +103,7 @@ export const PreviewTable = ({ // Tooltip contained inside the table so as not to overflow when table is scrolled const tooltipProps = useMemo( - () => ({ - PopperProps: { - container: tooltipContainerRef.current, - }, - }), + () => ({ PopperProps: { container: tooltipContainerRef.current } }), [] ); diff --git a/app/browser/dataset-preview.tsx b/app/browser/dataset-preview.tsx index 443235200..856773dd3 100644 --- a/app/browser/dataset-preview.tsx +++ b/app/browser/dataset-preview.tsx @@ -7,13 +7,15 @@ import * as React from "react"; import { DataSetPreviewTable } from "@/browse/datatable"; import { useFootnotesStyles } from "@/components/chart-footnotes"; -import { DataDownloadMenu, RunSparqlQuery } from "@/components/data-download"; +import { DataDownloadMenu } from "@/components/data-download"; import Flex from "@/components/flex"; import { HintRed, Loading, LoadingDataError } from "@/components/hint"; import { DataSource } from "@/config-types"; import { sourceToLabel } from "@/domain/datasource"; -import { useDataCubesComponentsQuery } from "@/graphql/hooks"; -import { useDataCubePreviewQuery } from "@/graphql/query-hooks"; +import { + useDataCubeMetadataQuery, + useDataCubePreviewQuery, +} from "@/graphql/query-hooks"; import { DataCubePublicationStatus } from "@/graphql/resolver-types"; import { useLocale } from "@/locales/use-locale"; @@ -93,55 +95,38 @@ export const DataSetPreview = ({ }) => { const footnotesClasses = useFootnotesStyles({ useMarginTop: false }); const locale = useLocale(); - const cubeFilters = [{ iri: dataSetIri }]; + const variables = { + sourceType: dataSource.type, + sourceUrl: dataSource.url, + locale, + cubeFilter: { iri: dataSetIri }, + }; + const [{ data: metadata, fetching: fetchingMetadata, error: metadataError }] = + useDataCubeMetadataQuery({ variables }); const [ { data: previewData, fetching: fetchingPreview, error: previewError }, - ] = useDataCubePreviewQuery({ - variables: { - iri: dataSetIri, - sourceType: dataSource.type, - sourceUrl: dataSource.url, - locale, - }, - }); - const [ - { - data: componentsData, - fetching: fetchingComponents, - error: componentsError, - }, - ] = useDataCubesComponentsQuery({ - variables: { - sourceType: dataSource.type, - sourceUrl: dataSource.url, - locale, - cubeFilters, - }, - }); + ] = useDataCubePreviewQuery({ variables }); const classes = useStyles({ - descriptionPresent: !!previewData?.dataCubeByIri?.description, + descriptionPresent: !!metadata?.dataCubeMetadata.description, }); React.useEffect(() => { window.scrollTo({ top: 0 }); }, []); - if (fetchingPreview || fetchingComponents) { + if (fetchingMetadata || fetchingPreview) { return ( ); - } else if ( - previewData?.dataCubeByIri && - componentsData?.dataCubesComponents - ) { - const { dataCubeByIri } = previewData; - const { dataCubesComponents } = componentsData; + } else if (metadata?.dataCubeMetadata && previewData?.dataCubePreview) { + const { dataCubeMetadata } = metadata; + const { dataCubePreview } = previewData; return ( - {dataCubeByIri.publicationStatus === + {dataCubeMetadata.publicationStatus === DataCubePublicationStatus.Draft && ( @@ -156,15 +141,15 @@ export const DataSetPreview = ({ - {dataCubeByIri.title} - visualize.admin.ch + {dataCubeMetadata.title} - visualize.admin.ch - {dataCubeByIri.title} + {dataCubeMetadata.title} - {dataCubeByIri.description && ( + {dataCubeMetadata.description && ( - {dataCubeByIri.description} + {dataCubeMetadata.description} )} - {dataCubeByIri.observations.sparqlEditorUrl && ( - - )} ); diff --git a/app/domain/data.ts b/app/domain/data.ts index a4d214200..1fb6b0f0f 100644 --- a/app/domain/data.ts +++ b/app/domain/data.ts @@ -40,21 +40,6 @@ export type HierarchyValue = { children?: HierarchyValue[]; }; -export type Observation = Record; - -export type DataCubeObservations = { - data: Observation[]; - sparqlEditorUrl: string; -}; - -export type DataCubesObservations = { - data: Observation[]; - sparqlEditorUrls: { - cubeIri: string; - url: string; - }[]; -}; - export type DataCubeComponents = { dimensions: Dimension[]; measures: Measure[]; @@ -82,6 +67,27 @@ export type DataCubeMetadata = { workExamples?: string[]; }; +export type Observation = Record; + +export type DataCubeObservations = { + data: Observation[]; + sparqlEditorUrl: string; +}; + +export type DataCubesObservations = { + data: Observation[]; + sparqlEditorUrls: { + cubeIri: string; + url: string; + }[]; +}; + +export type DataCubePreview = { + dimensions: Dimension[]; + measures: Measure[]; + observations: Observation[]; +}; + export type Component = Dimension | Measure; export type BaseComponent = { diff --git a/app/graphql/queries/data-cubes.graphql b/app/graphql/queries/data-cubes.graphql index 4cc9c08f2..a278e2e52 100644 --- a/app/graphql/queries/data-cubes.graphql +++ b/app/graphql/queries/data-cubes.graphql @@ -40,6 +40,20 @@ query DataCubeObservations( ) } +query DataCubePreview( + $sourceType: String! + $sourceUrl: String! + $locale: String! + $cubeFilter: DataCubePreviewFilter! +) { + dataCubePreview( + sourceType: $sourceType + sourceUrl: $sourceUrl + locale: $locale + cubeFilter: $cubeFilter + ) +} + query SearchCubes( $sourceType: String! $sourceUrl: String! @@ -64,38 +78,6 @@ query SearchCubes( } } -query DataCubePreview( - $iri: String! - $sourceType: String! - $sourceUrl: String! - $locale: String! - $latest: Boolean - $disableValuesLoad: Boolean = true -) { - dataCubeByIri( - iri: $iri - sourceType: $sourceType - sourceUrl: $sourceUrl - locale: $locale - latest: $latest - disableValuesLoad: $disableValuesLoad - ) { - iri - title - description - publicationStatus - observations( - sourceType: $sourceType - sourceUrl: $sourceUrl - preview: true - limit: 10 - ) { - data - sparqlEditorUrl - } - } -} - query GeoCoordinatesByDimensionIri( $dataCubeIri: String! $dimensionIri: String! diff --git a/app/graphql/query-hooks.ts b/app/graphql/query-hooks.ts index 436d9a706..1acad2aa5 100644 --- a/app/graphql/query-hooks.ts +++ b/app/graphql/query-hooks.ts @@ -1,6 +1,7 @@ import { DataCubeComponents } from '../domain/data'; import { DataCubeMetadata } from '../domain/data'; import { DataCubeObservations } from '../domain/data'; +import { DataCubePreview } from '../domain/data'; import { DimensionValue } from '../domain/data'; import { Filters } from '../configurator'; import { HierarchyValue } from '../domain/data'; @@ -24,6 +25,7 @@ export type Scalars = { DataCubeComponents: DataCubeComponents; DataCubeMetadata: DataCubeMetadata; DataCubeObservations: DataCubeObservations; + DataCubePreview: DataCubePreview; DimensionValue: DimensionValue; FilterValue: any; Filters: Filters; @@ -111,7 +113,6 @@ export type DataCubeMetadataFilter = { export type DataCubeObservationFilter = { iri: Scalars['String']; latest?: Maybe; - preview?: Maybe; filters?: Maybe; componentIris?: Maybe>; joinBy?: Maybe; @@ -124,6 +125,12 @@ export type DataCubeOrganization = { label?: Maybe; }; + +export type DataCubePreviewFilter = { + iri: Scalars['String']; + latest?: Maybe; +}; + export enum DataCubePublicationStatus { Draft = 'DRAFT', Published = 'PUBLISHED' @@ -384,6 +391,7 @@ export type Query = { dataCubeComponents: Scalars['DataCubeComponents']; dataCubeMetadata: Scalars['DataCubeMetadata']; dataCubeObservations: Scalars['DataCubeObservations']; + dataCubePreview: Scalars['DataCubePreview']; dataCubeByIri?: Maybe; possibleFilters: Array; searchCubes: Array; @@ -414,6 +422,14 @@ export type QueryDataCubeObservationsArgs = { }; +export type QueryDataCubePreviewArgs = { + sourceType: Scalars['String']; + sourceUrl: Scalars['String']; + locale: Scalars['String']; + cubeFilter: DataCubePreviewFilter; +}; + + export type QueryDataCubeByIriArgs = { sourceType: Scalars['String']; sourceUrl: Scalars['String']; @@ -613,30 +629,28 @@ export type DataCubeObservationsQueryVariables = Exact<{ export type DataCubeObservationsQuery = { __typename: 'Query', dataCubeObservations: DataCubeObservations }; -export type SearchCubesQueryVariables = Exact<{ +export type DataCubePreviewQueryVariables = Exact<{ sourceType: Scalars['String']; sourceUrl: Scalars['String']; locale: Scalars['String']; - query?: Maybe; - order?: Maybe; - includeDrafts?: Maybe; - filters?: Maybe | SearchCubeFilter>; + cubeFilter: DataCubePreviewFilter; }>; -export type SearchCubesQuery = { __typename: 'Query', searchCubes: Array<{ __typename: 'SearchCubeResult', highlightedTitle?: Maybe, highlightedDescription?: Maybe, cube: SearchCube }> }; +export type DataCubePreviewQuery = { __typename: 'Query', dataCubePreview: DataCubePreview }; -export type DataCubePreviewQueryVariables = Exact<{ - iri: Scalars['String']; +export type SearchCubesQueryVariables = Exact<{ sourceType: Scalars['String']; sourceUrl: Scalars['String']; locale: Scalars['String']; - latest?: Maybe; - disableValuesLoad?: Maybe; + query?: Maybe; + order?: Maybe; + includeDrafts?: Maybe; + filters?: Maybe | SearchCubeFilter>; }>; -export type DataCubePreviewQuery = { __typename: 'Query', dataCubeByIri?: Maybe<{ __typename: 'DataCube', iri: string, title: string, description?: Maybe, publicationStatus: DataCubePublicationStatus, observations: { __typename: 'ObservationsQuery', data: Array, sparqlEditorUrl?: Maybe } }> }; +export type SearchCubesQuery = { __typename: 'Query', searchCubes: Array<{ __typename: 'SearchCubeResult', highlightedTitle?: Maybe, highlightedDescription?: Maybe, cube: SearchCube }> }; export type GeoCoordinatesByDimensionIriQueryVariables = Exact<{ dataCubeIri: Scalars['String']; @@ -715,6 +729,20 @@ export const DataCubeObservationsDocument = gql` export function useDataCubeObservationsQuery(options: Omit, 'query'> = {}) { return Urql.useQuery({ query: DataCubeObservationsDocument, ...options }); }; +export const DataCubePreviewDocument = gql` + query DataCubePreview($sourceType: String!, $sourceUrl: String!, $locale: String!, $cubeFilter: DataCubePreviewFilter!) { + dataCubePreview( + sourceType: $sourceType + sourceUrl: $sourceUrl + locale: $locale + cubeFilter: $cubeFilter + ) +} + `; + +export function useDataCubePreviewQuery(options: Omit, 'query'> = {}) { + return Urql.useQuery({ query: DataCubePreviewDocument, ...options }); +}; export const SearchCubesDocument = gql` query SearchCubes($sourceType: String!, $sourceUrl: String!, $locale: String!, $query: String, $order: SearchCubeResultOrder, $includeDrafts: Boolean, $filters: [SearchCubeFilter!]) { searchCubes( @@ -736,36 +764,6 @@ export const SearchCubesDocument = gql` export function useSearchCubesQuery(options: Omit, 'query'> = {}) { return Urql.useQuery({ query: SearchCubesDocument, ...options }); }; -export const DataCubePreviewDocument = gql` - query DataCubePreview($iri: String!, $sourceType: String!, $sourceUrl: String!, $locale: String!, $latest: Boolean, $disableValuesLoad: Boolean = true) { - dataCubeByIri( - iri: $iri - sourceType: $sourceType - sourceUrl: $sourceUrl - locale: $locale - latest: $latest - disableValuesLoad: $disableValuesLoad - ) { - iri - title - description - publicationStatus - observations( - sourceType: $sourceType - sourceUrl: $sourceUrl - preview: true - limit: 10 - ) { - data - sparqlEditorUrl - } - } -} - `; - -export function useDataCubePreviewQuery(options: Omit, 'query'> = {}) { - return Urql.useQuery({ query: DataCubePreviewDocument, ...options }); -}; export const GeoCoordinatesByDimensionIriDocument = gql` query GeoCoordinatesByDimensionIri($dataCubeIri: String!, $dimensionIri: String!, $sourceType: String!, $sourceUrl: String!, $locale: String!, $latest: Boolean) { dataCubeByIri( diff --git a/app/graphql/resolver-types.ts b/app/graphql/resolver-types.ts index 1d11d17da..d62abb6e2 100644 --- a/app/graphql/resolver-types.ts +++ b/app/graphql/resolver-types.ts @@ -1,6 +1,7 @@ import { DataCubeComponents } from '../domain/data'; import { DataCubeMetadata } from '../domain/data'; import { DataCubeObservations } from '../domain/data'; +import { DataCubePreview } from '../domain/data'; import { DimensionValue } from '../domain/data'; import { Filters } from '../configurator'; import { HierarchyValue } from '../domain/data'; @@ -25,6 +26,7 @@ export type Scalars = { DataCubeComponents: DataCubeComponents; DataCubeMetadata: DataCubeMetadata; DataCubeObservations: DataCubeObservations; + DataCubePreview: DataCubePreview; DimensionValue: DimensionValue; FilterValue: any; Filters: Filters; @@ -112,7 +114,6 @@ export type DataCubeMetadataFilter = { export type DataCubeObservationFilter = { iri: Scalars['String']; latest?: Maybe; - preview?: Maybe; filters?: Maybe; componentIris?: Maybe>; joinBy?: Maybe; @@ -125,6 +126,12 @@ export type DataCubeOrganization = { label?: Maybe; }; + +export type DataCubePreviewFilter = { + iri: Scalars['String']; + latest?: Maybe; +}; + export enum DataCubePublicationStatus { Draft = 'DRAFT', Published = 'PUBLISHED' @@ -385,6 +392,7 @@ export type Query = { dataCubeComponents: Scalars['DataCubeComponents']; dataCubeMetadata: Scalars['DataCubeMetadata']; dataCubeObservations: Scalars['DataCubeObservations']; + dataCubePreview: Scalars['DataCubePreview']; dataCubeByIri?: Maybe; possibleFilters: Array; searchCubes: Array; @@ -415,6 +423,14 @@ export type QueryDataCubeObservationsArgs = { }; +export type QueryDataCubePreviewArgs = { + sourceType: Scalars['String']; + sourceUrl: Scalars['String']; + locale: Scalars['String']; + cubeFilter: DataCubePreviewFilter; +}; + + export type QueryDataCubeByIriArgs = { sourceType: Scalars['String']; sourceUrl: Scalars['String']; @@ -661,6 +677,8 @@ export type ResolversTypes = ResolversObject<{ DataCubeObservationFilter: DataCubeObservationFilter; DataCubeObservations: ResolverTypeWrapper; DataCubeOrganization: ResolverTypeWrapper; + DataCubePreview: ResolverTypeWrapper; + DataCubePreviewFilter: DataCubePreviewFilter; DataCubePublicationStatus: DataCubePublicationStatus; DataCubeTheme: ResolverTypeWrapper; Dimension: ResolverTypeWrapper; @@ -710,6 +728,8 @@ export type ResolversParentTypes = ResolversObject<{ DataCubeObservationFilter: DataCubeObservationFilter; DataCubeObservations: Scalars['DataCubeObservations']; DataCubeOrganization: DataCubeOrganization; + DataCubePreview: Scalars['DataCubePreview']; + DataCubePreviewFilter: DataCubePreviewFilter; DataCubeTheme: DataCubeTheme; Dimension: ResolvedDimension; DimensionValue: Scalars['DimensionValue']; @@ -784,6 +804,10 @@ export type DataCubeOrganizationResolvers; }>; +export interface DataCubePreviewScalarConfig extends GraphQLScalarTypeConfig { + name: 'DataCubePreview'; +} + export type DataCubeThemeResolvers = ResolversObject<{ iri?: Resolver; label?: Resolver, ParentType, ContextType>; @@ -961,6 +985,7 @@ export type QueryResolvers>; dataCubeMetadata?: Resolver>; dataCubeObservations?: Resolver>; + dataCubePreview?: Resolver>; dataCubeByIri?: Resolver, ParentType, ContextType, RequireFields>; possibleFilters?: Resolver, ParentType, ContextType, RequireFields>; searchCubes?: Resolver, ParentType, ContextType, RequireFields>; @@ -1052,6 +1077,7 @@ export type Resolvers = ResolversObject<{ DataCubeMetadata?: GraphQLScalarType; DataCubeObservations?: GraphQLScalarType; DataCubeOrganization?: DataCubeOrganizationResolvers; + DataCubePreview?: GraphQLScalarType; DataCubeTheme?: DataCubeThemeResolvers; Dimension?: DimensionResolvers; DimensionValue?: GraphQLScalarType; diff --git a/app/graphql/resolvers/index.ts b/app/graphql/resolvers/index.ts index ca20e7f93..7456b87ce 100644 --- a/app/graphql/resolvers/index.ts +++ b/app/graphql/resolvers/index.ts @@ -9,6 +9,7 @@ import { DataCubeResolvers, QueryResolvers, Resolvers, + ScaleType, } from "@/graphql/resolver-types"; import * as RDF from "@/graphql/resolvers/rdf"; import * as SQL from "@/graphql/resolvers/sql"; @@ -37,6 +38,10 @@ export const Query: QueryResolvers = { const source = getSource(args.sourceType); return await source.dataCubeObservations(parent, args, context, info); }, + dataCubePreview: async (parent, args, context, info) => { + const source = getSource(args.sourceType); + return await source.dataCubePreview(parent, args, context, info); + }, dataCubeByIri: async (parent, args, context, info) => { const source = getSource(args.sourceType); return await source.dataCubeByIri(parent, args, context, info); @@ -83,10 +88,10 @@ const DataCube: DataCubeResolvers = { }; export const resolveDimensionType = ( - component: ResolvedDimension + dataKind: ResolvedDimension["data"]["dataKind"] | undefined, + scaleType: ScaleType | undefined, + related: ResolvedDimension["data"]["related"] ): DimensionType => { - const { dataKind, scaleType, related } = component.data; - if (related.some((d) => d.type === "StandardError")) { return "StandardErrorDimension"; } @@ -113,16 +118,14 @@ export const resolveDimensionType = ( }; export const resolveMeasureType = ( - component: ResolvedDimension + scaleType: ScaleType | undefined ): MeasureType => { - const { scaleType } = component.data; - return scaleType === "Ordinal" ? "OrdinalMeasure" : "NumericalMeasure"; }; const mkDimensionResolvers = (_: string): Resolvers["Dimension"] => ({ - __resolveType(dimension) { - return resolveDimensionType(dimension); + __resolveType({ data: { dataKind, scaleType, related } }) { + return resolveDimensionType(dataKind, scaleType, related); }, iri: ({ data: { iri } }) => iri, label: ({ data: { name } }) => name, @@ -201,8 +204,8 @@ export const resolvers: Resolvers = { }, }, Dimension: { - __resolveType(dimension) { - return resolveDimensionType(dimension); + __resolveType({ data: { dataKind, scaleType, related } }) { + return resolveDimensionType(dataKind, scaleType, related); }, }, NominalDimension: { @@ -273,7 +276,7 @@ export const resolvers: Resolvers = { }, Measure: { __resolveType(dimension) { - return resolveMeasureType(dimension); + return resolveMeasureType(dimension.data.scaleType); }, }, NumericalMeasure: { diff --git a/app/graphql/resolvers/rdf.ts b/app/graphql/resolvers/rdf.ts index d59c72a5a..69cd3f3a0 100644 --- a/app/graphql/resolvers/rdf.ts +++ b/app/graphql/resolvers/rdf.ts @@ -30,6 +30,7 @@ import { getLatestCube, } from "@/rdf/queries"; import { getCubeMetadata } from "@/rdf/query-cube-metadata"; +import { getCubePreview } from "@/rdf/query-cube-preview"; import { unversionObservation } from "@/rdf/query-dimension-values"; import { queryHierarchy } from "@/rdf/query-hierarchies"; import { SearchResult, searchCubes as _searchCubes } from "@/rdf/query-search"; @@ -209,7 +210,7 @@ export const dataCubeComponents: NonNullable< if (data.isMeasureDimension) { const result: Measure = { - __typename: resolveMeasureType(component), + __typename: resolveMeasureType(component.data.scaleType), isCurrency: data.isCurrency, isDecimal: data.isDecimal, currencyExponent: data.currencyExponent, @@ -219,7 +220,12 @@ export const dataCubeComponents: NonNullable< measures.push(result); } else { - const dimensionType = resolveDimensionType(component); + const { dataKind, scaleType, related } = component.data; + const dimensionType = resolveDimensionType( + dataKind, + scaleType, + related + ); const hierarchy = true // TODO: make this configurable ? await queryHierarchy( component, @@ -322,6 +328,18 @@ export const dataCubeObservations: NonNullable< }; }; +export const dataCubePreview: NonNullable = + async (_, { locale, cubeFilter }, { setup }, info) => { + const { sparqlClient } = await setup(info); + const { iri, latest } = cubeFilter; + + return await getCubePreview(iri, { + locale, + latest: !!latest, + sparqlClient, + }); + }; + export const dataCubeDimensions: NonNullable = async ({ cube, locale }, { componentIris }, { setup }, info) => { const { sparqlClient, cache } = await setup(info); diff --git a/app/graphql/resolvers/sql.ts b/app/graphql/resolvers/sql.ts index 266b60ebc..3316469ec 100644 --- a/app/graphql/resolvers/sql.ts +++ b/app/graphql/resolvers/sql.ts @@ -324,3 +324,12 @@ export const dataCubeObservations: NonNullable< sparqlEditorUrl: "", }; }; + +export const dataCubePreview: NonNullable = + async () => { + return { + dimensions: [], + measures: [], + observations: [], + }; + }; diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index e8948abb9..eea01b77a 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -354,15 +354,20 @@ input DataCubeMetadataFilter { input DataCubeObservationFilter { iri: String! latest: Boolean - preview: Boolean filters: Filters componentIris: [String!] joinBy: String } +input DataCubePreviewFilter { + iri: String! + latest: Boolean +} + scalar DataCubeComponents scalar DataCubeMetadata scalar DataCubeObservations +scalar DataCubePreview # The "Query" type is special: it lists all of the available queries that # clients can execute, along with the return type for each. @@ -385,6 +390,12 @@ type Query { locale: String! cubeFilter: DataCubeObservationFilter! ): DataCubeObservations! + dataCubePreview( + sourceType: String! + sourceUrl: String! + locale: String! + cubeFilter: DataCubePreviewFilter! + ): DataCubePreview! dataCubeByIri( sourceType: String! sourceUrl: String! diff --git a/app/rdf/parse.ts b/app/rdf/parse.ts index 96d67c56d..853a6b846 100644 --- a/app/rdf/parse.ts +++ b/app/rdf/parse.ts @@ -112,9 +112,9 @@ const timeFormats = new Map([ [ns.xsd.dateTime.value, "%Y-%m-%dT%H:%M:%S"], ]); -export const getScaleType = (dim: CubeDimension): ScaleType | undefined => { - const scaleTypeTerm = dim.out(ns.qudt.scaleType).term; - +export const getScaleType = ( + scaleTypeTerm: Term | undefined +): ScaleType | undefined => { return scaleTypeTerm?.equals(ns.qudt.NominalScale) ? ScaleType.Nominal : scaleTypeTerm?.equals(ns.qudt.OrdinalScale) @@ -126,7 +126,7 @@ export const getScaleType = (dim: CubeDimension): ScaleType | undefined => { : undefined; }; -const getDataKind = (term: Term | undefined) => { +export const getDataKind = (term: Term | undefined) => { return term?.equals(ns.time.GeneralDateTimeDescription) ? "Time" : term?.equals(ns.schema.GeoCoordinates) @@ -228,18 +228,10 @@ export const parseCubeDimension = ({ parseDimensionDatatype(dim); const isDecimal = dataType?.equals(ns.xsd.decimal) ?? false; - const isNumerical = - dataType?.equals(ns.xsd.int) || - dataType?.equals(ns.xsd.integer) || - isDecimal || - dataType?.equals(ns.xsd.float) || - dataType?.equals(ns.xsd.double) || - false; - + const isNumerical = getIsNumerical(dataType); const isKeyDimension = dim .out(ns.rdf.type) .terms.some((t) => t.equals(ns.cube.KeyDimension)); - const isMeasureDimension = dim .out(ns.rdf.type) .terms.some((t) => t.equals(ns.cube.MeasureDimension)); @@ -248,14 +240,7 @@ export const parseCubeDimension = ({ const unitTerm = dim.out(ns.qudt.unit).term ?? dim.out(ns.qudt.hasUnit).term; const unit = unitTerm ? units?.get(unitTerm.value) : undefined; const unitLabel = unit?.label?.value; - - const rawOrder = dim.out(ns.sh.order).value; - const order = rawOrder !== undefined ? parseInt(rawOrder, 10) : undefined; - - const resolution = - dataType?.equals(ns.xsd.int) || dataType?.equals(ns.xsd.integer) - ? 0 - : undefined; + const resolution = parseResolution(dataType); return { cube, @@ -281,15 +266,45 @@ export const parseCubeDimension = ({ currencyExponent: unit?.currencyExponent?.value ? parseInt(unit.currencyExponent.value) : undefined, - order, + order: parseNumericalTerm(dim.out(ns.sh.order).term), dataKind: getDataKind(dataKindTerm), - timeUnit: timeUnits.get(timeUnitTerm?.value ?? ""), - timeFormat: timeFormats.get(dataType?.value ?? ""), - scaleType: getScaleType(dim), + timeUnit: getTimeUnit(timeUnitTerm), + timeFormat: getTimeFormat(dataType), + scaleType: getScaleType(dim.out(ns.qudt.scaleType).term), }, }; }; +export const parseNumericalTerm = (term: Term | undefined) => { + return term !== undefined ? parseInt(term.value, 10) : undefined; +}; + +export const getTimeUnit = (timeUnitTerm: Term | undefined) => { + return timeUnits.get(timeUnitTerm?.value ?? ""); +}; + +export const getTimeFormat = (dataTypeTerm: Term | undefined) => { + return timeFormats.get(dataTypeTerm?.value ?? ""); +}; + +export const parseResolution = (dataTypeTerm: Term | undefined) => { + return dataTypeTerm?.equals(ns.xsd.int) || + dataTypeTerm?.equals(ns.xsd.integer) + ? 0 + : undefined; +}; + +export const getIsNumerical = (dataTypeTerm: Term | undefined) => { + return ( + dataTypeTerm?.equals(ns.xsd.int) || + dataTypeTerm?.equals(ns.xsd.integer) || + dataTypeTerm?.equals(ns.xsd.float) || + dataTypeTerm?.equals(ns.xsd.double) || + dataTypeTerm?.equals(ns.xsd.decimal) || + false + ); +}; + const timeIntervals = new Map([ [ns.time.unitYear.value, timeYear], [ns.time.unitMonth.value, timeMonth], diff --git a/app/rdf/queries.ts b/app/rdf/queries.ts index 4b132c2f4..dbac475c0 100644 --- a/app/rdf/queries.ts +++ b/app/rdf/queries.ts @@ -400,7 +400,7 @@ export const getCubeDimensionValuesWithMetadata = async ({ const result: DimensionValue[] = []; if (namedNodes.length > 0) { - const scaleType = getScaleType(dimension); + const scaleType = getScaleType(dimension.out(ns.qudt.scaleType).term); const [labels, descriptions, literals, unversioned] = await Promise.all([ loadResourceLabels({ ids: namedNodes, diff --git a/app/rdf/query-cube-preview.spec.ts b/app/rdf/query-cube-preview.spec.ts new file mode 100644 index 000000000..110a83973 --- /dev/null +++ b/app/rdf/query-cube-preview.spec.ts @@ -0,0 +1,111 @@ +import rdf from "rdf-ext"; +import ParsingClient from "sparql-http-client/ParsingClient"; + +import * as ns from "./namespace"; +import { getCubePreview } from "./query-cube-preview"; + +jest.mock("rdf-cube-view-query", () => ({})); +jest.mock("./extended-cube", () => ({})); +jest.mock("@zazuko/cube-hierarchy-query/index", () => ({})); + +describe("dataset preview", () => { + const dim = rdf.blankNode(); + const measure = rdf.blankNode(); + const observation = rdf.namedNode( + "https://environment.ld.admin.ch/foen/gefahren-waldbrand-warnung/observation/336>" + ); + const quads = [ + rdf.quad( + dim, + ns.sh.path, + rdf.namedNode( + "https://environment.ld.admin.ch/foen/gefahren-waldbrand-warnung/region" + ) + ), + rdf.quad(dim, ns.schema.name, rdf.literal("Region")), + rdf.quad( + measure, + ns.sh.path, + rdf.namedNode( + "https://environment.ld.admin.ch/foen/gefahren-waldbrand-warnung/level" + ) + ), + rdf.quad(measure, ns.schema.name, rdf.literal("Danger ratings")), + rdf.quad(measure, ns.rdf.type, ns.cube.MeasureDimension), + rdf.quad( + observation, + rdf.namedNode( + "https://environment.ld.admin.ch/foen/gefahren-waldbrand-warnung/region" + ), + rdf.namedNode( + "https://ld.admin.ch/dimension/bgdi/biota/forestfirewarningregions/1300" + ) + ), + rdf.quad( + observation, + rdf.namedNode( + "https://environment.ld.admin.ch/foen/gefahren-waldbrand-warnung/region" + ), + rdf.literal("Bern") + ), + rdf.quad( + rdf.namedNode( + "https://ld.admin.ch/dimension/bgdi/biota/forestfirewarningregions/1300" + ), + ns.schema.position, + rdf.literal("3") + ), + rdf.quad( + observation, + rdf.namedNode( + "https://environment.ld.admin.ch/foen/gefahren-waldbrand-warnung/level" + ), + rdf.namedNode( + "https://environment.ld.admin.ch/foen/gefahren-waldbrand-warnung/level/1" + ) + ), + rdf.quad( + observation, + rdf.namedNode( + "https://environment.ld.admin.ch/foen/gefahren-waldbrand-warnung/level" + ), + rdf.literal("considerable danger") + ), + ]; + const sparqlClient = { + query: { + construct: async () => Promise.resolve(quads), + }, + } as any as ParsingClient; + + it("should return correct preview", async () => { + const { dimensions, measures, observations } = await getCubePreview( + "awesome iri", + { + sparqlClient, + locale: "en", + latest: true, + } + ); + const dim = dimensions[0]; + + expect(dim.iri).toEqual( + "https://environment.ld.admin.ch/foen/gefahren-waldbrand-warnung/region" + ); + expect(dim.label).toEqual("Region"); + expect(dim.values).toHaveLength(1); + expect(dim.values[0].position).toEqual(3); + + const measure = measures[0]; + + expect(measure.iri).toEqual( + "https://environment.ld.admin.ch/foen/gefahren-waldbrand-warnung/level" + ); + expect(measure.label).toEqual("Danger ratings"); + + const obs = observations[0]; + + expect(obs[dim.iri]).toEqual("Bern"); + expect(obs[measure.iri]).toEqual("considerable danger"); + }); +}); diff --git a/app/rdf/query-cube-preview.ts b/app/rdf/query-cube-preview.ts new file mode 100644 index 000000000..fca32b223 --- /dev/null +++ b/app/rdf/query-cube-preview.ts @@ -0,0 +1,321 @@ +import groupBy from "lodash/groupBy"; +import uniqBy from "lodash/uniqBy"; +import rdf from "rdf-ext"; +import { NamedNode, Quad } from "rdf-js"; +import ParsingClient from "sparql-http-client/ParsingClient"; + +import { + BaseComponent, + BaseDimension, + DataCubePreview, + Dimension, + DimensionValue, + Measure, + Observation, + TemporalDimension, +} from "@/domain/data"; +import { truthy } from "@/domain/types"; +import { resolveDimensionType, resolveMeasureType } from "@/graphql/resolvers"; + +import * as ns from "./namespace"; +import { + getDataKind, + getIsNumerical, + getScaleType, + getTimeFormat, + getTimeUnit, + parseNumericalTerm, + parseResolution, +} from "./parse"; +import { buildLocalizedSubQuery } from "./query-utils"; + +export const getCubePreview = async ( + iri: string, + options: { + locale: string; + latest: Boolean; + sparqlClient: ParsingClient; + } +): Promise => { + const { sparqlClient, locale } = options; + const qs = await sparqlClient.query.construct( + `PREFIX cube: +PREFIX meta: +PREFIX qudt: +PREFIX rdf: +PREFIX rdfs: +PREFIX schema: +PREFIX sh: +PREFIX time: +PREFIX xsd: + +CONSTRUCT { + ?dimension sh:path ?dimensionIri . + ?dimension sh:datatype ?dimensionDataType . + ?dimension rdf:type ?dimensionType . + ?dimension qudt:scaleType ?dimensionScaleType . + ?dimension qudt:unit ?dimensionUnit . + ?dimensionUnit schema:name ?dimensionUnitLabel . + ?dimensionUnit qudt:CurrencyUnit ?dimensionUnitIsCurrency . + ?dimensionUnit qudt:currencyExponent ?dimensionUnitCurrencyExponent . + ?dimension sh:order ?dimensionOrder . + ?dimension meta:dataKind ?dimensionDataKind . + ?dimensionDataKind rdf:type ?dimensionDataKindType . + ?dimensionDataKind time:unitType ?dimensionTimeUnitType . + ?dimension schema:name ?dimensionLabel . + ?dimension schema:description ?dimensionDescription . + + ?observation ?observationPredicate ?observationValue . + ?observation ?observationPredicate ?observationLabel . + ?observationValue schema:position ?observationPosition . +} WHERE { + VALUES ?cube { <${iri}> } + FILTER(EXISTS { ?cube a cube:Cube . }) {} + UNION { + ?cube cube:observationConstraint/sh:property ?dimension . + ?dimension sh:path ?dimensionIri . + OPTIONAL { ?dimension rdf:type ?dimensionType . } + OPTIONAL { ?dimension qudt:scaleType ?dimensionScaleType . } + OPTIONAL { + { ?dimension qudt:unit ?dimensionUnit . } + UNION { ?dimension qudt:hasUnit ?dimensionUnit . } + OPTIONAL { ?dimensionUnit rdfs:label ?dimensionUnitRdfsLabel . } + OPTIONAL { ?dimensionUnit qudt:symbol ?dimensionUnitSymbol . } + OPTIONAL { ?dimensionUnit qudt:ucumCode ?dimensionUnitUcumCode . } + OPTIONAL { ?dimensionUnit qudt:expression ?dimensionUnitExpression . } + OPTIONAL { ?dimensionUnit ?dimensionUnitIsCurrency qudt:CurrencyUnit . } + OPTIONAL { ?dimensionUnit qudt:currencyExponent ?dimensionUnitCurrencyExponent . } + BIND(STR(COALESCE(STR(?dimensionUnitSymbol), STR(?dimensionUnitUcumCode), STR(?dimensionUnitExpression), STR(?dimensionUnitRdfsLabel))) AS ?dimensionUnitLabel) + FILTER (LANG(?dimensionUnitRdfsLabel) = "en") + } + OPTIONAL { ?dimension sh:order ?dimensionOrder . } + OPTIONAL { + ?dimension meta:dataKind ?dimensionDataKind . + ?dimensionDataKind rdf:type ?dimensionDataKindType . + } + OPTIONAL { + ?dimension meta:dataKind ?dimensionDataKind . + ?dimensionDataKind time:unitType ?dimensionTimeUnitType . + } + ${buildLocalizedSubQuery("dimension", "schema:name", "dimensionLabel", { + locale, + })} + ${buildLocalizedSubQuery( + "dimension", + "schema:description", + "dimensionDescription", + { locale } + )} + FILTER(?dimensionIri != cube:observedBy && ?dimensionIri != rdf:type) + } UNION { + ?cube cube:observationConstraint/sh:property/sh:path ?observationPredicate . + { SELECT * WHERE { + { SELECT * WHERE { + ?cube cube:observationSet ?observationSet . + ?observationSet cube:observation ?observation . + FILTER(NOT EXISTS { ?cube cube:observationConstraint/sh:property/sh:datatype cube:Undefined . } && NOT EXISTS { ?observation ?p ""^^cube:Undefined . }) + } LIMIT 10 } + ?observation ?observationPredicate ?observationValue . + ${buildLocalizedSubQuery( + "observationValue", + "schema:name", + "observationLabel", + { locale } + )} + OPTIONAL { ?observationValue schema:position ?observationPosition . } + FILTER(?observationPredicate != cube:observedBy && ?observationPredicate != rdf:type) + }} + } +}`, + { operation: "postUrlencoded" } + ); + + if (qs.length === 0) { + throw new Error(`No cube found for ${iri}!`); + } + + const sQs = groupBy(qs, (q) => q.subject.value); + const spQs = Object.fromEntries( + Object.entries(sQs).map(([k, v]) => { + const pQs = groupBy(v, (q) => q.predicate.value); + return [k, pQs]; + }) + ); + + const dimensions: Dimension[] = []; + const measures: Measure[] = []; + const observations: Observation[] = []; + const qsDims = qs.filter(({ predicate: p }) => p.equals(ns.sh.path)); + const dimMetadataByDimIri = qsDims.reduce((acc, dim) => { + acc[dim.object.value] = { + values: [], + dataType: rdf.namedNode(""), + }; + return acc; + }, {} as Record); + // Only take quads that use dimension iris as predicates (observation values) + const qUniqueObservations = uniqBy( + qs.filter(({ predicate: p }) => qsDims.some((q) => q.object.equals(p))), + ({ subject: s }) => s.value + ); + qUniqueObservations.forEach(({ subject: s }) => { + const sqDimValues = uniqBy( + qsDims + .map((quad) => spQs[s.value]?.[quad.object.value]) + .flat() + .filter(truthy), + (d) => d.predicate.value + ); + const observation: Observation = {}; + sqDimValues.forEach((quad) => { + const qDimIri = quad.predicate; + const dimIri = qDimIri.value; + const qDimValue = quad.object; + let qPosition: Quad | undefined; + + if (!observation[dimIri]) { + // Retrieve the label of the observation value if it's a named node + if (qDimValue.termType === "NamedNode") { + const sIri = qs.find((q) => q.object.equals(quad.object)); + const qLabel = qs.find( + ({ subject: s, predicate: p, object: o }) => + s.equals(sIri?.subject) && + p.equals(qDimIri) && + o.termType === "Literal" + ); + + if (qLabel?.object.termType === "Literal") { + dimMetadataByDimIri[dimIri].dataType = qLabel.object.datatype; + } + + if (sIri?.object.value) { + qPosition = + spQs[sIri.object.value]?.[ns.schema.position.value]?.[0]; + } + + observation[qDimIri.value] = qLabel?.object.value ?? qDimValue.value; + } else { + if (qDimValue.termType === "Literal") { + dimMetadataByDimIri[dimIri].dataType = qDimValue.datatype; + } + + observation[qDimIri.value] = qDimValue.value; + } + } + + const dimensionValue: DimensionValue = { + value: qDimValue.value, + label: `${observation[qDimIri.value]}`, + position: qPosition ? +qPosition.object.value : undefined, + }; + dimMetadataByDimIri[dimIri].values.push(dimensionValue); + }); + + observations.push(observation); + }); + + for (const dimIri in dimMetadataByDimIri) { + dimMetadataByDimIri[dimIri].values = uniqBy( + dimMetadataByDimIri[dimIri].values, + (d) => d.value + ).sort((a, b) => + (a.position ?? a.label) > (b.position ?? b.label) ? 1 : -1 + ); + } + + qsDims.map(({ subject: s, object: o }) => { + const dimIri = o.value; + const qsDim = sQs[s.value]; + const pQsDim = groupBy(qsDim, (q) => q.predicate.value); + const qLabel = pQsDim[ns.schema.name.value]?.[0]; + const qDesc = pQsDim[ns.schema.description.value]?.[0]; + const qOrder = pQsDim[ns.sh.order.value]?.[0]; + const qsType = pQsDim[ns.rdf.type.value]; + const qScaleType = pQsDim[ns.qudt.scaleType.value]?.[0]; + const scaleType = getScaleType(qScaleType?.object); + const dataType = dimMetadataByDimIri[dimIri].dataType; + const qUnit = pQsDim[ns.qudt.unit.value]?.[0]; + const qUnitLabel = spQs[qUnit?.object.value]?.[ns.schema.name.value]?.[0]; + const qDataKind = pQsDim[ns.cube("meta/dataKind").value]?.[0]; + const qDataKindType = + spQs[qDataKind?.object.value]?.[ns.rdf.type.value]?.[0]; + const qTimeUnitType = + spQs[qDataKind?.object.value]?.[ns.time.unitType.value]?.[0]; + const qIsCurrency = + spQs[qUnit?.object.value]?.[ns.qudt.CurrencyUnit.value]?.[0]; + const qCurrencyExponent = + spQs[qUnit?.object.value]?.[ns.qudt.currencyExponent.value]?.[0]; + const isKeyDimension = qsType?.some((q) => + q.object.equals(ns.cube.KeyDimension) + ); + const isMeasureDimension = qsType?.some((q) => + q.object.equals(ns.cube.MeasureDimension) + ); + + const baseComponent: BaseComponent = { + cubeIri: iri, + iri: dimIri, + label: qLabel?.object.value ?? "", + description: qDesc?.object.value, + scaleType, + unit: qUnitLabel?.object.value, + order: parseNumericalTerm(qOrder?.object), + isNumerical: false, + isKeyDimension, + values: dimMetadataByDimIri[dimIri].values, + }; + + if (isMeasureDimension) { + const isDecimal = dataType.equals(ns.xsd.decimal) ?? false; + const result: Measure = { + ...baseComponent, + __typename: resolveMeasureType(scaleType), + isCurrency: qIsCurrency ? true : false, + isDecimal, + currencyExponent: parseNumericalTerm(qCurrencyExponent?.object), + resolution: parseResolution(dataType), + isNumerical: getIsNumerical(dataType), + }; + + measures.push(result); + } else { + const dimensionType = resolveDimensionType( + getDataKind(qDataKindType?.object), + scaleType, + [] + ); + const baseDimension: BaseDimension = baseComponent; + + switch (dimensionType) { + case "TemporalDimension": { + const timeUnit = getTimeUnit(qTimeUnitType?.object); + const timeFormat = getTimeFormat(dataType); + + if (!timeFormat || !timeUnit) { + throw new Error( + `Temporal dimension ${dimIri} has no timeFormat or timeUnit!` + ); + } + + const dimension: TemporalDimension = { + ...baseDimension, + __typename: dimensionType, + timeFormat, + timeUnit, + }; + dimensions.push(dimension); + break; + } + default: { + const dimension: Exclude = { + ...baseDimension, + __typename: dimensionType, + }; + dimensions.push(dimension); + } + } + } + }); + + return { dimensions, measures, observations }; +}; diff --git a/app/rdf/query-utils.ts b/app/rdf/query-utils.ts index 3200e5519..b38d718fa 100644 --- a/app/rdf/query-utils.ts +++ b/app/rdf/query-utils.ts @@ -13,9 +13,11 @@ export const buildLocalizedSubQuery = ( { locale, fallbackToNonLocalized, + additionalFallbacks, }: { locale: string; fallbackToNonLocalized?: boolean; + additionalFallbacks?: string[]; } ) => { // Include the empty locale as well. @@ -37,6 +39,10 @@ export const buildLocalizedSubQuery = ( } BIND(COALESCE(${locales.map((locale) => `?${o}_${locale}`).join(", ")}${ fallbackToNonLocalized ? `, ?${o}_raw` : `` + }${ + additionalFallbacks + ? ", " + additionalFallbacks.map((d) => `?${d}`).join(", ") + : "" }) AS ?${o})`; }; diff --git a/app/scripts/cube.ts b/app/scripts/cube.ts index 272873439..e897529b0 100644 --- a/app/scripts/cube.ts +++ b/app/scripts/cube.ts @@ -130,11 +130,10 @@ const previewCube = async ({ .query( DataCubePreviewDocument, { - iri, sourceType, sourceUrl, locale, - latest, + cubeFilter: { iri, latest }, } ) .toPromise(); @@ -143,7 +142,7 @@ const previewCube = async ({ throw new Error(res.error.message); } - report(res.data?.dataCubeByIri?.observations); + report(res.data?.dataCubePreview?.observations); }; const main = async () => { diff --git a/codegen.yml b/codegen.yml index 186faf866..af331dbd8 100644 --- a/codegen.yml +++ b/codegen.yml @@ -25,6 +25,7 @@ generates: DataCubeComponents: "../domain/data#DataCubeComponents" DataCubeMetadata: "../domain/data#DataCubeMetadata" DataCubeObservations: "../domain/data#DataCubeObservations" + DataCubePreview: "../domain/data#DataCubePreview" app/graphql/resolver-types.ts: plugins: - "typescript" @@ -45,6 +46,7 @@ generates: DataCubeComponents: "../domain/data#DataCubeComponents" DataCubeMetadata: "../domain/data#DataCubeMetadata" DataCubeObservations: "../domain/data#DataCubeObservations" + DataCubePreview: "../domain/data#DataCubePreview" mappers: DataCube: "./shared-types#ResolvedDataCube" ObservationsQuery: "./shared-types#ResolvedObservationsQuery"