Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions app/statistics/iris.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const SAFE_IRI = /^https?:\/\/[^ <>"{}|\\^`]+$/;

export const isSafeIri = (iri: string): boolean => {
return SAFE_IRI.test(iri);
};

export const chunk = <T>(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;
28 changes: 18 additions & 10 deletions app/statistics/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,29 +192,37 @@ export const fetchChartsMetadata = async () => {
jsonb_agg(
COALESCE(
data ->> 'dataSet',
chart_config_obj ->> 'dataSet',
data -> 'chartConfig' ->> 'dataSet',
cubes_obj ->> 'iri'
)
) AS iris,
COALESCE(
jsonb_agg(chart_config_array ->> 'chartType') FILTER (WHERE chart_config_array ->> 'chartType' IS NOT NULL),
jsonb_build_array(chart_config_obj ->> 'chartType')
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(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
GROUP BY day, data, chart_config_obj, layout, layout_subtype
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 -> 'cubes') = 'array' THEN chart_config_array -> 'cubes'
ELSE '[]'::jsonb
END
) AS cubes_obj ON true
GROUP BY config.id, day
)
SELECT day, iris, chart_types, layout_type, layout_subtype
FROM filtered_data
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 ~ '^https?://[^ <>"{}|\\^]+$')
);
`.then((rows) => {
return rows.map((row) => ({
Expand Down
61 changes: 48 additions & 13 deletions app/statistics/sparql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
};
Loading