Skip to content
Open
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
15 changes: 14 additions & 1 deletion src/wfs/queryPreparation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
compileDwithinSpatialFilter,
compileIntersectsFeatureSpatialFilter,
compileIntersectsPointSpatialFilter,
encodedCqlFilterBytes,
MAX_CQL_FILTER_BYTES,
} from "./spatialCql.js";
import type { Geometry } from "geojson";

Expand Down Expand Up @@ -207,7 +209,9 @@ export function compileQueryParts(
if (!resolvedGeometryRef) {
throw new Error("Le filtre spatial `intersects_feature` exige la résolution préalable de la géométrie de référence.");
}
fragments.push(compileIntersectsFeatureSpatialFilter(geometryName, resolvedGeometryRef));
fragments.push(
compileIntersectsFeatureSpatialFilter(geometryName, resolvedGeometryRef, spatialFilter.typename),
);
break;
case "travel_time":
if (!resolvedGeometryRef) {
Expand All @@ -226,6 +230,15 @@ export function compileQueryParts(
}

const cqlFilter = fragments.length > 0 ? fragments.join(" AND ") : undefined;
if (cqlFilter) {
const cqlFilterBytes = encodedCqlFilterBytes(cqlFilter);
if (cqlFilterBytes > MAX_CQL_FILTER_BYTES) {
throw new Error(
`Le filtre CQL est trop volumineux : ${cqlFilterBytes} octets une fois encodé (limite : ${MAX_CQL_FILTER_BYTES}). ` +
"Réduire le nombre de clauses attributaires ou simplifier les filtres.",
);
}
}

if (!isGetFeatures) {
// for CountFeatures: only return the required parts
Expand Down
17 changes: 11 additions & 6 deletions src/wfs/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { GPF_WFS_URL } from "./catalog.js";
import type { CompiledQuery } from "./queryPreparation.js";
import { queryIsGetFeaturesInput, type GpfQueryFeaturesInput } from "./schema.js";
import { MAX_CQL_FILTER_BYTES } from "./spatialCql.js";

// --- Transport Types ---

Expand All @@ -26,12 +27,20 @@ export type CompiledRequest = {
*
* @param cqlFilter Compiled CQL filter, when the request needs one.
* @returns The encoded POST body, or an empty string when no filter is present.
* @throws When the body exceeds `MAX_CQL_FILTER_BYTES`, since the WFS would silently ignore the filter.
*/
function buildBody(cqlFilter?: string) {
if (!cqlFilter) {
return "";
}
return new URLSearchParams({ cql_filter: cqlFilter }).toString();
const body = new URLSearchParams({ cql_filter: cqlFilter }).toString();
// The URL-encoded body is pure ASCII: its length is its size in bytes.
if (body.length > MAX_CQL_FILTER_BYTES) {
throw new Error(
`Le filtre CQL est trop volumineux : ${body.length} octets une fois encodé (limite : ${MAX_CQL_FILTER_BYTES}).`,
);
}
return body;
}

// --- Public Builders ---
Expand Down Expand Up @@ -164,14 +173,10 @@ export function buildMultiTypenameRequest(
srsName: "EPSG:4326",
};

const body = expandedCqlFilter
? new URLSearchParams({ cql_filter: expandedCqlFilter }).toString()
: "";

return {
method: "POST",
url: GPF_WFS_URL,
query,
body,
body: buildBody(expandedCqlFilter),
};
}
60 changes: 57 additions & 3 deletions src/wfs/spatialCql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,33 @@ import type { Geometry } from "geojson";
import type { SpatialFilter } from "./schema.js";
import { geometryToEwkt } from "./geometry.js";

/**
* Maximum size of the URL-encoded `cql_filter=...` POST body accepted by the Géoplateforme WFS.
* Beyond it, the server silently ignores `cql_filter` and returns the whole layer (measured on 2026-10-13).
*/
export const MAX_CQL_FILTER_BYTES = 2 * 1024 * 1024;

/**
* Computes the size of the POST body carrying a CQL filter, to compare with `MAX_CQL_FILTER_BYTES`.
*
* @param cqlFilter Compiled CQL filter.
* @returns The size in bytes of the URL-encoded body (pure ASCII, so its length is its byte size).
*/
export function encodedCqlFilterBytes(cqlFilter: string) {
return new URLSearchParams({ cql_filter: cqlFilter }).toString().length;
}

const SMALL_SCALE_VERSIONS = ["2025", "2026", "LATEST"];

/** Maps `ADMINEXPRESS-COG.<version>:<layer>` to its CARTO-PE (small-scale) twin, if applicable. */
function toSmallScaleTypename(typename: string): string | undefined {
const match = /^ADMINEXPRESS-COG\.([^:.]+):(.+)$/.exec(typename);
if (!match || !SMALL_SCALE_VERSIONS.includes(match[1])) {
return undefined;
}
return `ADMINEXPRESS-COG-CARTO-PE.${match[1]}:${match[2]}`;
}

// --- Spatial Predicate Compilation ---

/**
Expand Down Expand Up @@ -51,12 +78,39 @@ export function compileDwithinSpatialFilter(geometryName: string, spatialFilter:
}

/**
* Compiles an `intersects_feature` spatial filter from the resolved reference geometry.
* Compiles an `intersects_feature` spatial filter from the resolved reference geometry.
* Throw if the filter is too big
*
* @param geometryName Geometry property already resolved for the feature type.
* @param geometry Reference geometry.
* @param referenceTypename Optional typename of the reference feature, used for error diagnostic.
* @returns A CQL intersects predicate.
* @throws When the predicate exceeds the budget.
*/
export function compileIntersectsFeatureSpatialFilter(geometryName: string, geometry: Geometry) {
return `INTERSECTS(${geometryName},${geometryToEwkt(geometry)})`;
export function compileIntersectsFeatureSpatialFilter(
geometryName: string,
geometry: Geometry,
referenceTypename?: string,
) {
const exactFilter = `INTERSECTS(${geometryName},${geometryToEwkt(geometry)})`;
const filterBytes = encodedCqlFilterBytes(exactFilter);
if (filterBytes <= MAX_CQL_FILTER_BYTES) {
return exactFilter;
}

const recommendation = (() => {
const peTwin = referenceTypename ? toSmallScaleTypename(referenceTypename) : undefined;
if (peTwin) {
return `Choisir une géométrie de référence moins détaillée (privilégier la couche '${peTwin}' au lieu de '${referenceTypename}') ou utiliser un filtre bbox.`;
}
if (referenceTypename?.includes("CARTO-PE")) {
return "Choisir une géométrie de référence moins détaillée ou utiliser un filtre bbox.";
}
return "Choisir une géométrie de référence moins détaillée (privilégier les couches ADMINEXPRESS-COG-CARTO-PE pour les surfaces administratives) ou utiliser un filtre bbox.";
})();

throw new Error([
`La géométrie de référence est trop volumineuse pour \`intersects_feature\`: ${filterBytes} octets de filtre encodé (limite : ${MAX_CQL_FILTER_BYTES}).`,
recommendation,
].join("\n"));
}
9 changes: 9 additions & 0 deletions test/wfs/queryPreparation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,15 @@ describe("gpfGetFeatures/queryPreparation", () => {
);
});

it("should reject queries when combined CQL filter exceeds MAX_CQL_FILTER_BYTES once URL-encoded", () => {
// ~1.6 MB as raw text, ~2.3 MB once URL-encoded
const hugeWhereValues = Array.from({ length: 120_000 }, (_, i) => `val_${i}`);
expect(() => compileQueryParts({
...baseInput,
where: [{ property: "code_insee", operator: "in", values: hugeWhereValues }],
}, wrappedFeatureType)).toThrow(/Le filtre CQL est trop volumineux/);
});

it("should build sortBy from structured order_by", () => {
const compiled = compileQueryParts({
...baseInput,
Expand Down
52 changes: 52 additions & 0 deletions test/wfs/referenceGeometry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";

import type { GpfFeatureType } from "../../src/wfs/catalog.js";
import type { CompiledRequest } from "../../src/wfs/request.js";
import {
resolveFeatureGeometry,
type ReferenceGeometryClient,
} from "../../src/wfs/referenceGeometry.js";

const geometry = { type: "Point", coordinates: [2, 48] } as const;

function featureType(typename: string): GpfFeatureType {
return {
typename,
schema: { properties: { geom: { format: "geometry-point" }, nom: { type: "string" } } },
} as unknown as GpfFeatureType;
}

function makeClient(knownTypenames: string[]) {
const getFeatureType = vi.fn(async (typename: string) => {
if (!knownTypenames.includes(typename)) {
throw new Error(`unknown ${typename}`);
}
return featureType(typename);
});
const fetchFeatureCollection = vi.fn(async (_request: CompiledRequest) => ({
type: "FeatureCollection",
features: [{ type: "Feature", id: "commune.1", geometry, properties: {} }],
}));
const client = { getFeatureType, fetchFeatureCollection } as unknown as ReferenceGeometryClient;
return { client, getFeatureType, fetchFeatureCollection };
}

const ref = (typename: string) => ({ typename, feature_id: "commune.1" });

describe("resolveFeatureGeometry", () => {
it.each([
"ADMINEXPRESS-COG.2025:commune",
"ADMINEXPRESS-COG.2026:commune",
"ADMINEXPRESS-COG.LATEST:commune",
"ADMINEXPRESS-COG.2024:commune",
"BDTOPO_V3:batiment",
])("queries the requested typename %s directly without automatic remapping", async (typename) => {
const { client, getFeatureType, fetchFeatureCollection } = makeClient([typename]);

await expect(resolveFeatureGeometry(client, ref(typename))).resolves.toEqual(geometry);

expect(getFeatureType).toHaveBeenCalledTimes(1);
expect(getFeatureType).toHaveBeenCalledWith(typename);
expect(fetchFeatureCollection.mock.calls[0][0].query.typeNames).toBe(typename);
});
});
41 changes: 41 additions & 0 deletions test/wfs/request.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { buildMultiTypenameRequest } from "../../src/wfs/request";
import { MAX_CQL_FILTER_BYTES } from "../../src/wfs/spatialCql";

/** Builds a `code_insee IN (...)` filter: under the limit as raw text, but ~1.7x larger once URL-encoded. */
function inFilter(count: number) {
return `code_insee IN (${Array.from({ length: count }, (_, i) => `'${i}'`).join(",")})`;
}

describe("wfs_engine/request", () => {
it("should build a multi-typename request with encoded cql_filter", () => {
Expand Down Expand Up @@ -90,4 +96,39 @@ describe("wfs_engine/request", () => {
expect(request.body).toContain("cql_filter=");
expect(request.body.length).toBeGreaterThan(6000);
});

it("accepts a POST body of exactly MAX_CQL_FILTER_BYTES and rejects one byte more", () => {
const filterAtLimit = "a".repeat(MAX_CQL_FILTER_BYTES - "cql_filter=".length);
const request = buildMultiTypenameRequest({
typenames: ["ADMINEXPRESS-COG.LATEST:commune"],
cqlFilter: filterAtLimit,
});
expect(request.body.length).toBe(MAX_CQL_FILTER_BYTES);

expect(() => buildMultiTypenameRequest({
typenames: ["ADMINEXPRESS-COG.LATEST:commune"],
cqlFilter: `${filterAtLimit}a`,
})).toThrow(/Le filtre CQL est trop volumineux/);
});

it("rejects cqlFilter when its URL-encoded body exceeds MAX_CQL_FILTER_BYTES", () => {
const hugeFilter = inFilter(200_000);
expect(Buffer.byteLength(hugeFilter)).toBeLessThan(MAX_CQL_FILTER_BYTES);
expect(() => buildMultiTypenameRequest({
typenames: ["ADMINEXPRESS-COG.LATEST:commune"],
cqlFilter: hugeFilter,
})).toThrow(/Le filtre CQL est trop volumineux/);
});

it("rejects multi-typename cqlFilters when expanded filter exceeds MAX_CQL_FILTER_BYTES", () => {
const largeFilter = inFilter(100_000);
expect(() => buildMultiTypenameRequest({
typenames: ["ADMINEXPRESS-COG.LATEST:commune"],
cqlFilter: largeFilter,
})).not.toThrow();
expect(() => buildMultiTypenameRequest({
typenames: ["ADMINEXPRESS-COG.LATEST:commune", "ADMINEXPRESS-COG.LATEST:departement"],
cqlFilters: [largeFilter, largeFilter],
})).toThrow(/Le filtre CQL est trop volumineux/);
});
});
47 changes: 47 additions & 0 deletions test/wfs/spatialCql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import {
compileIntersectsPointSpatialFilter,
compileDwithinSpatialFilter,
compileIntersectsFeatureSpatialFilter,
encodedCqlFilterBytes,
MAX_CQL_FILTER_BYTES,
} from "../../src/wfs/spatialCql";

import type { SpatialFilter } from "../../src/wfs/schema";
import { geometryToEwkt } from "../../src/wfs/geometry";

// --- Shared fixtures ---

Expand Down Expand Up @@ -124,6 +127,12 @@ describe("compileDwithinSpatialFilter", () => {
// --- compileIntersectsFeatureSpatialFilter ---

describe("compileIntersectsFeatureSpatialFilter", () => {
// Fits under the limit as raw text, but not once URL-encoded (`(`, `)`, `,` take 3 bytes each).
const oversizedGeometry = {
type: "MultiPoint" as const,
coordinates: Array.from({ length: 120_000 }, (_, index) => [2 + index / 100_000, 48]),
};

it("should compile an intersects_feature filter with EWKT geometry", () => {
const geometry = {
type: "MultiPolygon" as const,
Expand All @@ -141,6 +150,44 @@ describe("compileIntersectsFeatureSpatialFilter", () => {

expect(result).toEqual("INTERSECTS(the_geom,SRID=4326;POINT(2.3522 48.8566))");
});

it("should preserve small filter geometries even when their vertices could be simplified", () => {
const geometry = {
type: "LineString" as const,
coordinates: [[2, 48], [2.1, 48.00001], [2.2, 48]],
};

expect(compileIntersectsFeatureSpatialFilter(geometryName, geometry)).toEqual(
`INTERSECTS(the_geom,${geometryToEwkt(geometry)})`,
);
});

it("should reject filter geometries whose URL-encoded filter is oversized", () => {
const filter = `INTERSECTS(the_geom,${geometryToEwkt(oversizedGeometry)})`;
expect(Buffer.byteLength(filter)).toBeLessThan(MAX_CQL_FILTER_BYTES);
expect(encodedCqlFilterBytes(filter)).toBeGreaterThan(MAX_CQL_FILTER_BYTES);
expect(() => compileIntersectsFeatureSpatialFilter(geometryName, oversizedGeometry)).toThrow(
/géométrie.*trop volumineuse.*/,
);
});

it("should suggest the corresponding -CARTO-PE layer when reference is from ADMINEXPRESS-COG", () => {
expect(() =>
compileIntersectsFeatureSpatialFilter(geometryName, oversizedGeometry, "ADMINEXPRESS-COG.LATEST:departement"),
).toThrow(/ADMINEXPRESS-COG-CARTO-PE\.LATEST:departement/);
});

it("should suggest using -CARTO-PE when reference is not small-scale", () => {
expect(() =>
compileIntersectsFeatureSpatialFilter(geometryName, oversizedGeometry, "BDTOPO_V3:batiment"),
).toThrow(/ADMINEXPRESS-COG-CARTO-PE/);
});

it("should not recommend -CARTO-PE when already using a -CARTO-PE reference layer", () => {
expect(() =>
compileIntersectsFeatureSpatialFilter(geometryName, oversizedGeometry, "ADMINEXPRESS-COG-CARTO-PE.LATEST:departement"),
).not.toThrow(/ADMINEXPRESS-COG-CARTO-PE/);
});
});

// --- Helpers ---
Expand Down
Loading