From aec8b98733efb819d8b5a85f381645112dffc726 Mon Sep 17 00:00:00 2001 From: Max Burri Date: Fri, 6 Nov 2026 10:27:11 +0100 Subject: [PATCH 1/4] fix: make the statistics query more robust - prevents crashes of the statistics page for corrupt configs Assisted-By: Devin --- app/statistics/prisma.ts | 48 ++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/app/statistics/prisma.ts b/app/statistics/prisma.ts index 9f51009131..9c32b51e99 100644 --- a/app/statistics/prisma.ts +++ b/app/statistics/prisma.ts @@ -193,20 +193,54 @@ export const fetchChartsMetadata = async () => { COALESCE( data ->> 'dataSet', chart_config_obj ->> 'dataSet', - cubes_obj ->> 'iri' + CASE + WHEN jsonb_typeof(cubes_obj) = 'object' THEN cubes_obj ->> 'iri' + END ) ) AS iris, COALESCE( - jsonb_agg(chart_config_array ->> 'chartType') FILTER (WHERE chart_config_array ->> 'chartType' IS NOT NULL), + jsonb_agg( + CASE + WHEN jsonb_typeof(chart_config_array) = 'object' THEN chart_config_array ->> 'chartType' + END + ) FILTER ( + WHERE jsonb_typeof(chart_config_array) = 'object' + AND chart_config_array ->> 'chartType' IS NOT NULL + ), jsonb_build_array(chart_config_obj ->> 'chartType') ) AS chart_types, layout -> 'type' AS layout_type, layout -> 'layout' AS layout_subtype FROM config - LEFT JOIN LATERAL jsonb_array_elements(data -> 'chartConfigs') AS chart_config_array ON true - LEFT JOIN LATERAL jsonb_array_elements(chart_config_array -> 'cubes') AS cubes_obj ON true - LEFT JOIN LATERAL (SELECT data -> 'chartConfig' AS chart_config_obj) AS single_config ON true - LEFT JOIN LATERAL (SELECT data -> 'layout' AS layout) AS layout ON true + LEFT JOIN LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(data -> 'chartConfigs') = 'array' THEN data -> 'chartConfigs' + ELSE '[]'::jsonb + END + ) AS chart_config_array ON true + LEFT JOIN LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(chart_config_array) = 'object' THEN + CASE + WHEN jsonb_typeof(chart_config_array -> 'cubes') = 'array' THEN chart_config_array -> 'cubes' + ELSE '[]'::jsonb + END + ELSE '[]'::jsonb + END + ) AS cubes_obj ON true + LEFT JOIN LATERAL ( + SELECT + CASE + WHEN jsonb_typeof(data -> 'chartConfig') = 'object' THEN data -> 'chartConfig' + END AS chart_config_obj + ) AS single_config ON true + LEFT JOIN LATERAL ( + SELECT + CASE + WHEN jsonb_typeof(data -> 'layout') = 'object' THEN data -> 'layout' + END AS layout + ) AS layout ON true + WHERE jsonb_typeof(data) = 'object' GROUP BY day, data, chart_config_obj, layout, layout_subtype ) SELECT day, iris, chart_types, layout_type, layout_subtype @@ -214,7 +248,7 @@ export const fetchChartsMetadata = async () => { WHERE NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(iris) AS iri - WHERE NOT (iri LIKE 'http://%' OR iri LIKE 'https://%') + WHERE iri IS NULL OR NOT (iri LIKE 'http://%' OR iri LIKE 'https://%') ); `.then((rows) => { return rows.map((row) => ({ From 54dc29ff68a1de0267eb911d1109268e43d28828 Mon Sep 17 00:00:00 2001 From: Max Burri Date: Fri, 6 Nov 2026 10:35:15 +0100 Subject: [PATCH 2/4] ref: improve statistics query after review Co-Authored-By: Devin --- app/statistics/prisma.ts | 42 ++++++++-------------------------------- 1 file changed, 8 insertions(+), 34 deletions(-) diff --git a/app/statistics/prisma.ts b/app/statistics/prisma.ts index 9c32b51e99..b5a91b42e3 100644 --- a/app/statistics/prisma.ts +++ b/app/statistics/prisma.ts @@ -192,25 +192,16 @@ export const fetchChartsMetadata = async () => { jsonb_agg( COALESCE( data ->> 'dataSet', - chart_config_obj ->> 'dataSet', - CASE - WHEN jsonb_typeof(cubes_obj) = 'object' THEN cubes_obj ->> 'iri' - END + data -> 'chartConfig' ->> 'dataSet', + cubes_obj ->> 'iri' ) ) AS iris, COALESCE( - jsonb_agg( - CASE - WHEN jsonb_typeof(chart_config_array) = 'object' THEN chart_config_array ->> 'chartType' - END - ) FILTER ( - WHERE jsonb_typeof(chart_config_array) = 'object' - AND chart_config_array ->> 'chartType' IS NOT NULL - ), - jsonb_build_array(chart_config_obj ->> 'chartType') + jsonb_agg(chart_config_array ->> 'chartType') FILTER (WHERE chart_config_array ->> 'chartType' IS NOT NULL), + jsonb_build_array(data -> 'chartConfig' ->> 'chartType') ) AS chart_types, - layout -> 'type' AS layout_type, - layout -> 'layout' AS layout_subtype + data -> 'layout' -> 'type' AS layout_type, + data -> 'layout' -> 'layout' AS layout_subtype FROM config LEFT JOIN LATERAL jsonb_array_elements( CASE @@ -220,28 +211,11 @@ export const fetchChartsMetadata = async () => { ) AS chart_config_array ON true LEFT JOIN LATERAL jsonb_array_elements( CASE - WHEN jsonb_typeof(chart_config_array) = 'object' THEN - CASE - WHEN jsonb_typeof(chart_config_array -> 'cubes') = 'array' THEN chart_config_array -> 'cubes' - ELSE '[]'::jsonb - END + WHEN jsonb_typeof(chart_config_array -> 'cubes') = 'array' THEN chart_config_array -> 'cubes' ELSE '[]'::jsonb END ) AS cubes_obj ON true - LEFT JOIN LATERAL ( - SELECT - CASE - WHEN jsonb_typeof(data -> 'chartConfig') = 'object' THEN data -> 'chartConfig' - END AS chart_config_obj - ) AS single_config ON true - LEFT JOIN LATERAL ( - SELECT - CASE - WHEN jsonb_typeof(data -> 'layout') = 'object' THEN data -> 'layout' - END AS layout - ) AS layout ON true - WHERE jsonb_typeof(data) = 'object' - GROUP BY day, data, chart_config_obj, layout, layout_subtype + GROUP BY config.id, day ) SELECT day, iris, chart_types, layout_type, layout_subtype FROM filtered_data From 5e916674794fcf97856275ae32e70913ef4e2f64 Mon Sep 17 00:00:00 2001 From: Max Burri Date: Fri, 6 Nov 2026 10:48:59 +0100 Subject: [PATCH 3/4] fix(statistics): validate cube IRIs and tolerate SPARQL failures --- app/statistics/iris.ts | 13 +++++++++ app/statistics/prisma.ts | 2 +- app/statistics/sparql.ts | 61 +++++++++++++++++++++++++++++++--------- 3 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 app/statistics/iris.ts diff --git a/app/statistics/iris.ts b/app/statistics/iris.ts new file mode 100644 index 0000000000..21808abace --- /dev/null +++ b/app/statistics/iris.ts @@ -0,0 +1,13 @@ +const SAFE_IRI = /^https?:\/\/[^ <>"{}|\\^`]+$/; + +export const isSafeIri = (iri: string): boolean => { + return SAFE_IRI.test(iri); +}; + +export const chunk = (items: T[], size: number): T[][] => { + return Array.from({ length: Math.ceil(items.length / size) }, (_, i) => + items.slice(i * size, i * size + size) + ); +}; + +export const IRIS_CHUNK_SIZE = 100; diff --git a/app/statistics/prisma.ts b/app/statistics/prisma.ts index b5a91b42e3..3e5f567abc 100644 --- a/app/statistics/prisma.ts +++ b/app/statistics/prisma.ts @@ -222,7 +222,7 @@ export const fetchChartsMetadata = async () => { WHERE NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(iris) AS iri - WHERE iri IS NULL OR NOT (iri LIKE 'http://%' OR iri LIKE 'https://%') + WHERE iri IS NULL OR NOT (iri ~ '^https?://[^ <>"{}|\\^]+$') ); `.then((rows) => { return rows.map((row) => ({ diff --git a/app/statistics/sparql.ts b/app/statistics/sparql.ts index 144c90eefc..2d0fe0e4a6 100644 --- a/app/statistics/sparql.ts +++ b/app/statistics/sparql.ts @@ -2,6 +2,11 @@ import ParsingClient from "sparql-http-client/ParsingClient"; import { DEFAULT_DATA_SOURCE } from "@/domain/data-source"; import { buildLocalizedSubQuery, iriToNode } from "@/rdf/query-utils"; +import { + chunk, + IRIS_CHUNK_SIZE, + isSafeIri, +} from "@/statistics/iris"; const sparqlClient = new ParsingClient({ endpointUrl: DEFAULT_DATA_SOURCE.url, @@ -46,21 +51,51 @@ export const queryCubesMetadata = async ({ cubeIris: string[]; locale: string; }): Promise< - { iri: string; unversionedIri: string; title: string; creator: string }[] + { iri: string; unversionedIri: string; title: string; creator?: string }[] > => { - const query = getCubeMetadataQuery({ cubeIris, locale }); - const results = await sparqlClient.query.select(query, { - operation: "postUrlencoded", + const safeCubeIris = cubeIris.filter(isSafeIri); + const normalize = ( + iri: string, + unversionedIri?: string, + title?: string, + creator?: string + ): { iri: string; unversionedIri: string; title: string; creator?: string } => ({ + iri, + unversionedIri: unversionedIri ?? iri, + title: title ?? iri, + creator, }); - return results.map((result) => { - const iri = result.iri.value; + const queryChunk = async ( + iris: string[] + ): Promise< + { iri: string; unversionedIri: string; title: string; creator?: string }[] + > => { + const query = getCubeMetadataQuery({ cubeIris: iris, locale }); - return { - iri, - unversionedIri: result.unversionedIri.value ?? iri, - title: result.title?.value ?? iri, - creator: result.creator?.value, - }; - }); + try { + const rows = await sparqlClient.query.select(query, { + operation: "postUrlencoded", + }); + + return rows.flatMap((result) => { + const iri = result.iri?.value; + + return typeof iri === "string" + ? [ + normalize( + iri, + result.unversionedIri?.value, + result.title?.value, + result.creator?.value + ), + ] + : []; + }); + } catch { + return iris.map((iri) => normalize(iri)); + } + }; + + return (await Promise.all(chunk(safeCubeIris, IRIS_CHUNK_SIZE).map(queryChunk))).flat(); }; From 71dab4a978ebcad6472df68bd353c5a57c166416 Mon Sep 17 00:00:00 2001 From: Max Burri Date: Fri, 6 Nov 2026 12:00:00 +0100 Subject: [PATCH 4/4] doc: add changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c8947720..f7075b7bfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ You can also check the - Features - add link to https://status.lindas.admin.ch/ in footer +- Fixes + - Statistics page no longer fails when the database contains malformed chart + configs; unsafe cube IRIs are filtered out and SPARQL metadata lookups + tolerate endpoint failures ## 6.5.5 - 2026-10-26