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
14 changes: 14 additions & 0 deletions .changeset/query-orderby-unpivot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'dsfr-data': patch
---

`dsfr-data-query` : l'`order-by` (et le group-by/where) n'est plus délégué en
tri serveur quand la chaîne entre la query et la source qui fetch contient un
transformateur qui crée ou renomme des colonnes — `dsfr-data-unpivot`
(toujours) ou `dsfr-data-normalize` avec `rename`/`compute`/`flatten`/
`lowercase-keys`. Les opérations de la query s'expriment dans le schéma
POST-transformation : les pousser au serveur envoyait des noms de colonnes
inconnus de l'API (Grist Records : `?sort=annee` → 500 `unknown key`) et
mettait la source partagée en erreur pour tous ses abonnés. Le tri s'exécute
désormais côté client, sur les données transformées (#394). Nouveau hook
optionnel `transformsSchema()` sur le contrat `SourceElement`.
18 changes: 18 additions & 0 deletions packages/core/src/components/dsfr-data-normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,24 @@ export class DsfrDataNormalize extends TransformerMixin(LitElement) {
return null;
}

/**
* True si la normalisation crée/renomme des colonnes (#394) : rename,
* compute, flatten et lowercase-keys changent les clés — les opérations
* serveur d'une query aval porteraient sur des noms inconnus de l'API.
* Sinon (transformations de valeurs uniquement : numeric, trim…), le
* statut est délégué à l'amont — un unpivot peut précéder ce normalize.
*/
public transformsSchema(): boolean {
if (this.rename || this.compute || this.flatten || this.lowercaseKeys) return true;
if (this.source) {
const sourceEl = document.getElementById(this.source);
if (sourceEl && 'transformsSchema' in sourceEl) {
return (sourceEl as unknown as SourceElement).transformsSchema?.() === true;
}
}
return false;
}

createRenderRoot() {
return this;
}
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/components/dsfr-data-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,15 @@ export class DsfrDataQuery extends TransformerMixin(LitElement) {
const adapter = sourceEl?.getAdapter?.();
const caps: AdapterCapabilities | undefined = adapter?.capabilities;

if (sourceEl && adapter && caps) {
// #394 : si un transformateur entre cette query et la source qui fetch
// crée/renomme des colonnes (unpivot, normalize rename/compute…), les
// opérations de la query s'expriment dans le schéma POST-transformation.
// Les déléguer au serveur enverrait des noms de colonnes inconnus de
// l'API (Grist Records : 500 "unknown key annee") — et l'erreur de la
// source ferait tomber TOUS ses abonnés. Tout reste alors client-side.
const upstreamTransformsSchema = sourceEl?.transformsSchema?.() === true;

if (sourceEl && adapter && caps && !upstreamTransformsSchema) {
// Don't override if dsfr-data-source already has its own groupBy/aggregate
// (user explicitly configured them on the source — respect that)
const sourceGroupBy = sourceEl.groupBy || '';
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/components/dsfr-data-unpivot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,16 @@ export class DsfrDataUnpivot extends TransformerMixin(LitElement) {
return null;
}

/**
* L'unpivot crée toujours des colonnes (var-name/value-name) et supprime
* les colonnes dépliées : le schéma aval ne correspond jamais au schéma
* de la source qui fetch (#394). Une query en aval ne doit donc jamais
* déléguer ses opérations (order-by…) au serveur à travers ce composant.
*/
public transformsSchema(): boolean {
return true;
}

getData(): Row[] {
return this._data;
}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/utils/source-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,15 @@ export interface SourceElement extends HTMLElement {
* délèguent vers leur amont (join : vers la source gauche).
*/
getAdapterParams?(): AdapterParams | null;

/**
* True si ce composant (ou un transformateur en amont de lui) crée,
* renomme ou supprime des colonnes par rapport au schéma de la source qui
* fetch (#394) : unpivot (toujours), normalize avec rename/compute/
* flatten/lowercase-keys. dsfr-data-query s'en sert pour NE PAS déléguer
* ses opérations (order-by, group-by, where) au serveur : elles
* s'expriment dans le schéma POST-transformation, inconnu de l'API
* (Grist Records : 500 "unknown key"). Absent = schéma préservé.
*/
transformsSchema?(): boolean;
}
229 changes: 229 additions & 0 deletions tests/query-orderby-unpivot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

/**
* Tests #394 — `order-by` d'une query en aval d'un transformateur créateur
* de colonnes (unpivot, normalize rename/compute) ne doit PAS être poussé
* en tri serveur.
*
* Bug d'origine : query → unpivot → source Grist. La négociation serveur de
* dsfr-data-query atteignait l'adapter Grist à travers la délégation
* transparente getAdapter() de l'unpivot, déléguait `order-by="annee:asc"`
* (caps.serverOrderBy), et le relais de commandes du TransformerMixin
* remontait la commande jusqu'à la source → `GET …/records?sort=annee` →
* 500 `unknown key annee` (la colonne `annee` est créée par l'unpivot, elle
* n'existe pas dans la table Grist wide `c2017…c2026`). Toute la chaîne en
* aval de la source tombait (pattern « un fetch, N consommateurs »).
*
* Contrat fixé : quand la chaîne entre la query et la source qui fetch
* contient un transformateur qui crée/renomme des colonnes
* (transformsSchema() === true), la query ne délègue RIEN au serveur — ses
* opérations s'expriment dans le schéma POST-transformation. Le tri se fait
* client-side sur les données transformées.
*/

const mockFetch = vi.fn();
globalThis.fetch = mockFetch;

import { DsfrDataQuery } from '@/components/dsfr-data-query.js';
import { DsfrDataUnpivot } from '@/components/dsfr-data-unpivot.js';
import { DsfrDataNormalize } from '@/components/dsfr-data-normalize.js';
import {
clearDataCache,
clearDataMeta,
dispatchDataLoaded,
subscribeToSourceCommands,
} from '@/utils/data-bridge.js';

// Table wide représentative du cas Plan_Elec (#394) : le temps est encodé
// dans les NOMS de colonnes ; `annee` n'existe pas côté serveur.
const WIDE_ROWS = [
{
Indicateur: 'Électricité produite totale',
c2017: '529',
c2018: '548',
c2019: '537',
},
{
Indicateur: 'Autre indicateur',
c2017: '10',
c2018: '11',
c2019: '12',
},
];

/** Source factice exposant un adapter aux capabilities Grist (tri serveur supporté). */
function makeGristLikeSourceEl(id: string): HTMLElement {
const el = document.createElement('div');
el.id = id;
(el as unknown as Record<string, unknown>).getAdapter = () => ({
type: 'grist',
capabilities: {
serverFetch: true,
serverGroupBy: true,
serverOrderBy: true,
whereFormat: 'colon',
},
});
document.body.appendChild(el);
return el;
}

describe('#394 — order-by en aval d’un unpivot : jamais de tri serveur', () => {
let sourceEl: HTMLElement;
let unpivot: DsfrDataUnpivot;
let query: DsfrDataQuery;
let sourceCommands: Array<Record<string, unknown>>;
let tidyCommands: Array<Record<string, unknown>>;
let unsubs: Array<() => void>;

beforeEach(() => {
for (const id of ['g394-src', 'g394-tidy', 'g394-q']) {
clearDataCache(id);
clearDataMeta(id);
}
sourceEl = makeGristLikeSourceEl('g394-src');

unpivot = new DsfrDataUnpivot();
unpivot.id = 'g394-tidy';
unpivot.source = 'g394-src';
unpivot.idCols = 'Indicateur';
unpivot.valueColsPattern = 'c{YYYY}';
unpivot.varName = 'annee';
unpivot.varFormat = '{YYYY}';
unpivot.valueName = 'valeur';
document.body.appendChild(unpivot);

sourceCommands = [];
tidyCommands = [];
unsubs = [
subscribeToSourceCommands('g394-src', (cmd) =>
sourceCommands.push(cmd as Record<string, unknown>)
),
subscribeToSourceCommands('g394-tidy', (cmd) =>
tidyCommands.push(cmd as Record<string, unknown>)
),
];

query = new DsfrDataQuery();
query.id = 'g394-q';
query.source = 'g394-tidy';
});

afterEach(() => {
(query as any)._cleanup();
unsubs.forEach((fn) => fn());
unpivot.remove();
sourceEl.remove();
});

it('AC : l’order-by sur une colonne créée par l’unpivot n’est pas relayé en tri serveur', () => {
query.orderBy = 'annee:asc';
(query as any)._initialize();

// Aucune commande orderBy ne doit atteindre ni l'unpivot ni la source —
// avant le fix, la source recevait { orderBy: 'annee:asc' } et refetchait
// avec ?sort=annee → 500 unknown key.
expect(tidyCommands.filter((c) => 'orderBy' in c)).toHaveLength(0);
expect(sourceCommands.filter((c) => 'orderBy' in c)).toHaveLength(0);
expect((query as any)._serverDelegated.orderBy).toBe(false);
});

it('AC : repli en tri client sur les données transformées (post-unpivot)', () => {
query.orderBy = 'annee:desc';
(query as any)._initialize();

// La source émet le tableau wide ; l'unpivot le transforme ; la query
// trie client-side sur la colonne créée `annee`.
dispatchDataLoaded('g394-src', WIDE_ROWS);

const data = query.getData() as Array<Record<string, unknown>>;
expect(data).toHaveLength(6);
expect(data.map((r) => r.annee)).toEqual(['2019', '2019', '2018', '2018', '2017', '2017']);
});

it('where + group-by non plus : aucune délégation quand le schéma amont est transformé', () => {
// Même contrat pour les autres opérations : `annee` (where) et `valeur`
// (aggregate) n'existent pas côté serveur — délégation interdite.
query.where = 'annee:gte:2018';
query.groupBy = 'Indicateur';
query.aggregate = 'valeur:sum:total';
(query as any)._initialize();

expect(tidyCommands).toHaveLength(0);
expect(sourceCommands).toHaveLength(0);

dispatchDataLoaded('g394-src', WIDE_ROWS);
const data = query.getData() as Array<Record<string, unknown>>;
// 2 indicateurs, agrégés client-side sur les années >= 2018
expect(data).toHaveLength(2);
expect(data[0]).toMatchObject({ Indicateur: 'Électricité produite totale', total: 1085 });
});

it('contrôle (non-régression) : query branchée directement sur la source délègue toujours', () => {
const direct = new DsfrDataQuery();
direct.id = 'g394-direct';
direct.source = 'g394-src';
direct.orderBy = 'Valeur:desc';
(direct as any)._initialize();

expect(sourceCommands.filter((c) => c.orderBy === 'Valeur:desc')).toHaveLength(1);
expect((direct as any)._serverDelegated.orderBy).toBe(true);

(direct as any)._cleanup();
(direct as any)._clearServerDelegation();
clearDataCache('g394-direct');
clearDataMeta('g394-direct');
});
});

describe('#394 — transformsSchema() sur la chaîne de transformateurs', () => {
afterEach(() => {
document.body.querySelectorAll('dsfr-data-unpivot, dsfr-data-normalize, div').forEach((el) => {
if (el.id.startsWith('ts394-')) el.remove();
});
});

it('unpivot transforme toujours le schéma', () => {
const unpivot = new DsfrDataUnpivot();
expect(unpivot.transformsSchema()).toBe(true);
});

it('normalize avec rename ou compute transforme le schéma', () => {
const withRename = new DsfrDataNormalize();
withRename.rename = 'pop:Population';
expect(withRename.transformsSchema()).toBe(true);

const withCompute = new DsfrDataNormalize();
withCompute.compute = 'pct = valeur * 100';
expect(withCompute.transformsSchema()).toBe(true);
});

it('normalize purement valeur (numeric-auto, trim) préserve le schéma', () => {
const plain = new DsfrDataNormalize();
plain.numericAuto = true;
plain.trim = true;
expect(plain.transformsSchema()).toBe(false);
});

it('normalize pass-through relaie le statut de son amont (chaîne normalize → unpivot)', () => {
const unpivot = new DsfrDataUnpivot();
unpivot.id = 'ts394-tidy';
document.body.appendChild(unpivot);

const normalize = new DsfrDataNormalize();
normalize.numericAuto = true;
normalize.source = 'ts394-tidy';
expect(normalize.transformsSchema()).toBe(true);
});

it('normalize pass-through sur une source simple préserve le schéma', () => {
const src = document.createElement('div');
src.id = 'ts394-src';
document.body.appendChild(src);

const normalize = new DsfrDataNormalize();
normalize.numericAuto = true;
normalize.source = 'ts394-src';
expect(normalize.transformsSchema()).toBe(false);
});
});
Loading