From 31a971a48839af4c22c3683a244dd465e3d17468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Pilet?= Date: Mon, 10 Aug 2026 22:20:11 +0200 Subject: [PATCH] model: escape single quotes in Edm.String literals EdmStringTypTraits.to_literal wrapped the value in single quotes without escaping quotes contained in the value itself. An embedded single quote in an OData string literal has to be doubled, so any filter on a value containing an apostrophe produced a malformed query that services reject. Against a live OData v2 service (ws.parlament.ch), filtering on the French term "delit d'initie" yielded: $filter=Language eq 'FR' and Title eq 'delit d'initie' HTTP 400 - Syntax error at position 45 The literal terminates early at the apostrophe and the remainder is parsed as syntax. With the quote doubled the same query returns HTTP 200. from_literal is updated symmetrically to collapse doubled quotes, so that to_literal/from_literal round-trip. Values with no quotes are unaffected in both directions. Note: to_literal now calls str(value) explicitly because the interpolation that stringified the value implicitly is replaced by a .replace() call. This does not change which types are accepted. --- CHANGELOG.md | 4 ++++ pyodata/v2/model.py | 4 ++-- tests/test_model_v2.py | 11 +++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9025349..14bd059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Fixed + +- model: escape single quotes in `Edm.String` literals, so filters on values containing an apostrophe produce a valid OData query - Francois Pilet + ## [1.12.0] diff --git a/pyodata/v2/model.py b/pyodata/v2/model.py index 7312b01..0470ed3 100644 --- a/pyodata/v2/model.py +++ b/pyodata/v2/model.py @@ -570,7 +570,7 @@ class EdmStringTypTraits(TypTraits): # pylint: disable=no-self-use def to_literal(self, value): - return '\'%s\'' % (value) + return '\'%s\'' % (str(value).replace("'", "''")) # pylint: disable=no-self-use def from_json(self, value): @@ -578,7 +578,7 @@ def from_json(self, value): def from_literal(self, value): if len(value) >= 2 and value[0] == "'" and value[-1] == "'": - return value[1:-1] + return value[1:-1].replace("''", "'") return value diff --git a/tests/test_model_v2.py b/tests/test_model_v2.py index 32aa207..87c669b 100644 --- a/tests/test_model_v2.py +++ b/tests/test_model_v2.py @@ -430,6 +430,17 @@ def test_traits(): assert typ.traits.from_literal("only right'") == "only right'" assert typ.traits.from_literal("'") == "'" + # EdmStringTypTraits.to_literal — a single quote inside the value must be doubled, + # otherwise the resulting literal terminates early and the server rejects the query + assert typ.traits.to_literal("O'Brien") == "'O''Brien'" + assert typ.traits.to_literal("l'Etat") == "'l''Etat'" + assert typ.traits.to_literal("''") == "''''''" + assert typ.traits.to_literal('no quotes') == "'no quotes'" + + # EdmStringTypTraits — to_literal/from_literal must round-trip + for value in ["O'Brien", "l'Etat", "a''b", "'wrapped'", 'no quotes', '']: + assert typ.traits.from_literal(typ.traits.to_literal(value)) == value + # bool typ = Types.from_name('Edm.Boolean') assert repr(typ.traits) == 'EdmBooleanTypTraits'