Skip to content

Commit 8726a59

Browse files
Fix the Serialization/Deserialization issue with $ prefix columns (#2944)
Serialization and deserialization of metadata currently fail when column names are prefixed with the $ symbol. This issue occurs because we’ve enabled the ReferenceHandler flag in our System.Text.Json serialization settings. When this flag is active, the serializer treats $ as a reserved character used for special metadata (e.g., $id, $ref). As a result, any property name starting with $ is interpreted as metadata and cannot be deserialized properly. This update introduces custom logic in the converter’s Write and Read methods to handle $-prefixed column names safely. - During serialization, columns beginning with $ are escaped as "_$". - During deserialization, this transformation is reversed to restore the original property names. - [x] Unit tests --------- Co-authored-by: Aniruddh Munde <anmunde@microsoft.com> Fix serialization for StoredProcedureDefinition inheritance (#3045) - To apply correct serialization and deserialization logic for stored procedures. With the previous changes, serialization was not working correctly for the StoredProcedureDefinition type, which extends SourceDefinition. When the value type was passed explicitly for serialization, the parent type was used instead, causing some child-type properties to be omitted. Instead of manually specifying the value type during serialization, this change allows the library to infer the type automatically and perform the correct serialization. - [x] Unit Tests --------- Co-authored-by: Aniruddh Munde <anmunde@microsoft.com> Update src/Core/Services/MetadataProviders/Converters/DatabaseObjectConverter.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Fix documentation typos in serialization comments (#3057) Code review identified typos in XML documentation comments that inaccurately described the escaping mechanism and contained spelling errors. Fixed documentation comments to accurately reflect the implementation: - **DatabaseObjectConverter.cs**: Updated escape/unescape method summaries to correctly describe the `DAB_ESCAPE$` prefix transformation (previously incorrectly documented as `_$`) - **SerializationDeserializationTests.cs**: Corrected "deserilization" → "deserialization" in three test method comments - [x] Documentation-only change, no functional changes <!-- START COPILOT CODING AGENT TIPS --> --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Alekhya-Polavarapu <67075378+Alekhya-Polavarapu@users.noreply.github.com>
1 parent 353838b commit 8726a59

2 files changed

Lines changed: 188 additions & 7 deletions

File tree

src/Core/Services/MetadataProviders/Converters/DatabaseObjectConverter.cs

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ namespace Azure.DataApiBuilder.Core.Services.MetadataProviders.Converters
1717
public class DatabaseObjectConverter : JsonConverter<DatabaseObject>
1818
{
1919
private const string TYPE_NAME = "TypeName";
20+
private const string DOLLAR_CHAR = "$";
21+
22+
// ``DAB_ESCAPE$`` is used to escape column names that start with `$` during serialization.
23+
// It is chosen to be unique enough to avoid collisions with actual column names.
24+
private const string ESCAPED_DOLLARCHAR = "DAB_ESCAPE$";
2025

2126
public override DatabaseObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
2227
{
@@ -29,6 +34,15 @@ public override DatabaseObject Read(ref Utf8JsonReader reader, Type typeToConver
2934

3035
DatabaseObject objA = (DatabaseObject)JsonSerializer.Deserialize(document, concreteType, options)!;
3136

37+
foreach (PropertyInfo prop in objA.GetType().GetProperties().Where(IsSourceDefinitionOrDerivedClassProperty))
38+
{
39+
SourceDefinition? sourceDef = (SourceDefinition?)prop.GetValue(objA);
40+
if (sourceDef is not null)
41+
{
42+
UnescapeDollaredColumns(sourceDef);
43+
}
44+
}
45+
3246
return objA;
3347
}
3448
}
@@ -58,12 +72,72 @@ public override void Write(Utf8JsonWriter writer, DatabaseObject value, JsonSeri
5872
}
5973

6074
writer.WritePropertyName(prop.Name);
61-
JsonSerializer.Serialize(writer, prop.GetValue(value), options);
75+
object? propVal = prop.GetValue(value);
76+
77+
// Only escape columns for properties whose type(derived type) is SourceDefinition.
78+
if (IsSourceDefinitionOrDerivedClassProperty(prop) && propVal is SourceDefinition sourceDef)
79+
{
80+
EscapeDollaredColumns(sourceDef);
81+
}
82+
83+
JsonSerializer.Serialize(writer, propVal, options);
6284
}
6385

6486
writer.WriteEndObject();
6587
}
6688

89+
private static bool IsSourceDefinitionOrDerivedClassProperty(PropertyInfo prop)
90+
{
91+
// Return true for properties whose type is SourceDefinition or any class derived from SourceDefinition
92+
return typeof(SourceDefinition).IsAssignableFrom(prop.PropertyType);
93+
}
94+
95+
/// <summary>
96+
/// Escapes column keys that start with '$' by prefixing them with 'DAB_ESCAPE$' for serialization.
97+
/// </summary>
98+
private static void EscapeDollaredColumns(SourceDefinition sourceDef)
99+
{
100+
if (sourceDef.Columns is null || sourceDef.Columns.Count == 0)
101+
{
102+
return;
103+
}
104+
105+
List<string> keysToEscape = sourceDef.Columns.Keys
106+
.Where(k => k.StartsWith(DOLLAR_CHAR, StringComparison.Ordinal))
107+
.ToList();
108+
109+
foreach (string key in keysToEscape)
110+
{
111+
ColumnDefinition col = sourceDef.Columns[key];
112+
sourceDef.Columns.Remove(key);
113+
string newKey = ESCAPED_DOLLARCHAR + key[1..];
114+
sourceDef.Columns[newKey] = col;
115+
}
116+
}
117+
118+
/// <summary>
119+
/// Unescapes column keys that start with 'DAB_ESCAPE$' by removing the prefix and restoring the original '$' for deserialization.
120+
/// </summary>
121+
private static void UnescapeDollaredColumns(SourceDefinition sourceDef)
122+
{
123+
if (sourceDef.Columns is null || sourceDef.Columns.Count == 0)
124+
{
125+
return;
126+
}
127+
128+
List<string> keysToUnescape = sourceDef.Columns.Keys
129+
.Where(k => k.StartsWith(ESCAPED_DOLLARCHAR, StringComparison.Ordinal))
130+
.ToList();
131+
132+
foreach (string key in keysToUnescape)
133+
{
134+
ColumnDefinition col = sourceDef.Columns[key];
135+
sourceDef.Columns.Remove(key);
136+
string newKey = DOLLAR_CHAR + key[ESCAPED_DOLLARCHAR.Length..];
137+
sourceDef.Columns[newKey] = col;
138+
}
139+
}
140+
67141
private static Type GetTypeFromName(string typeName)
68142
{
69143
Type? type = Type.GetType(typeName);

src/Service.Tests/UnitTests/SerializationDeserializationTests.cs

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,114 @@ public void TestDictionaryDatabaseObjectSerializationDeserialization()
276276
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseTable.TableDefinition, _databaseTable.TableDefinition, "FirstName");
277277
}
278278

279-
private void InitializeObjects()
279+
/// <summary>
280+
/// Validates serialization and deserialization of Dictionary containing DatabaseTable
281+
/// The table will have dollar sign prefix ($) in the column name
282+
/// this is how we serialize and deserialize metadataprovider.EntityToDatabaseObject dict.
283+
/// </summary>
284+
[TestMethod]
285+
public void TestDictionaryDatabaseObjectSerializationDeserialization_WithDollarColumn()
286+
{
287+
InitializeObjects(generateDollaredColumn: true);
288+
289+
_options = new()
290+
{
291+
Converters = {
292+
new DatabaseObjectConverter(),
293+
new TypeConverter()
294+
},
295+
ReferenceHandler = ReferenceHandler.Preserve
296+
};
297+
298+
Dictionary<string, DatabaseObject> dict = new() { { "person", _databaseTable } };
299+
300+
string serializedDict = JsonSerializer.Serialize(dict, _options);
301+
// Assert that the serialized JSON contains the escaped dollar sign in column name
302+
Assert.IsTrue(serializedDict.Contains("DAB_ESCAPE$FirstName"),
303+
"Serialized JSON should contain the dollar-prefixed column name in SourceDefinition's Columns.");
304+
305+
Dictionary<string, DatabaseObject> deserializedDict = JsonSerializer.Deserialize<Dictionary<string, DatabaseObject>>(serializedDict, _options)!;
306+
DatabaseTable deserializedDatabaseTable = (DatabaseTable)deserializedDict["person"];
307+
308+
Assert.AreEqual(deserializedDatabaseTable.SourceType, _databaseTable.SourceType);
309+
Assert.AreEqual(deserializedDatabaseTable.FullName, _databaseTable.FullName);
310+
deserializedDatabaseTable.Equals(_databaseTable);
311+
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseTable.SourceDefinition, _databaseTable.SourceDefinition, "$FirstName");
312+
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseTable.TableDefinition, _databaseTable.TableDefinition, "$FirstName");
313+
}
314+
315+
/// <summary>
316+
/// Validates serialization and deserialization of Dictionary containing DatabaseView
317+
/// The table will have dollar sign prefix ($) in the column name
318+
/// this is how we serialize and deserialize metadataprovider.EntityToDatabaseObject dict.
319+
/// </summary>
320+
[TestMethod]
321+
public void TestDatabaseViewSerializationDeserialization_WithDollarColumn()
322+
{
323+
InitializeObjects(generateDollaredColumn: true);
324+
325+
TestTypeNameChanges(_databaseView, "DatabaseView");
326+
327+
Dictionary<string, DatabaseObject> dict = new();
328+
dict.Add("person", _databaseView);
329+
330+
// Test to catch if there is change in number of properties/fields
331+
// Note: On Addition of property make sure it is added in following object creation _databaseView and include in serialization
332+
// and deserialization test.
333+
int fields = typeof(DatabaseView).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).Length;
334+
Assert.AreEqual(fields, 6);
335+
336+
string serializedDatabaseView = JsonSerializer.Serialize(dict, _options);
337+
// Assert that the serialized JSON contains the escaped dollar sign in column name
338+
Assert.IsTrue(serializedDatabaseView.Contains("DAB_ESCAPE$FirstName"),
339+
"Serialized JSON should contain the dollar-prefixed column name in SourceDefinition's Columns.");
340+
Dictionary<string, DatabaseObject> deserializedDict = JsonSerializer.Deserialize<Dictionary<string, DatabaseObject>>(serializedDatabaseView, _options)!;
341+
342+
DatabaseView deserializedDatabaseView = (DatabaseView)deserializedDict["person"];
343+
344+
Assert.AreEqual(deserializedDatabaseView.SourceType, _databaseView.SourceType);
345+
deserializedDatabaseView.Equals(_databaseView);
346+
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseView.SourceDefinition, _databaseView.SourceDefinition, "$FirstName");
347+
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseView.ViewDefinition, _databaseView.ViewDefinition, "$FirstName");
348+
}
349+
350+
/// <summary>
351+
/// Validates serialization and deserialization of Dictionary containing DatabaseStoredProcedure
352+
/// The table will have dollar sign prefix ($) in the column name
353+
/// this is how we serialize and deserialize metadataprovider.EntityToDatabaseObject dict.
354+
/// </summary>
355+
[TestMethod]
356+
public void TestDatabaseStoredProcedureSerializationDeserialization_WithDollarColumn()
280357
{
358+
InitializeObjects(generateDollaredColumn: true);
359+
360+
TestTypeNameChanges(_databaseStoredProcedure, "DatabaseStoredProcedure");
361+
362+
Dictionary<string, DatabaseObject> dict = new();
363+
dict.Add("person", _databaseStoredProcedure);
364+
365+
// Test to catch if there is change in number of properties/fields
366+
// Note: On Addition of property make sure it is added in following object creation _databaseStoredProcedure and include in serialization
367+
// and deserialization test.
368+
int fields = typeof(DatabaseStoredProcedure).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).Length;
369+
Assert.AreEqual(fields, 6);
370+
371+
string serializedDatabaseSP = JsonSerializer.Serialize(dict, _options);
372+
// Assert that the serialized JSON contains the escaped dollar sign in column name
373+
Assert.IsTrue(serializedDatabaseSP.Contains("DAB_ESCAPE$FirstName"),
374+
"Serialized JSON should contain the dollar-prefixed column name in SourceDefinition's Columns.");
375+
Dictionary<string, DatabaseObject> deserializedDict = JsonSerializer.Deserialize<Dictionary<string, DatabaseObject>>(serializedDatabaseSP, _options)!;
376+
DatabaseStoredProcedure deserializedDatabaseSP = (DatabaseStoredProcedure)deserializedDict["person"];
377+
378+
Assert.AreEqual(deserializedDatabaseSP.SourceType, _databaseStoredProcedure.SourceType);
379+
deserializedDatabaseSP.Equals(_databaseStoredProcedure);
380+
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseSP.SourceDefinition, _databaseStoredProcedure.SourceDefinition, "$FirstName", true);
381+
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseSP.StoredProcedureDefinition, _databaseStoredProcedure.StoredProcedureDefinition, "$FirstName", true);
382+
}
383+
384+
private void InitializeObjects(bool generateDollaredColumn = false)
385+
{
386+
string columnName = generateDollaredColumn ? "$FirstName" : "FirstName";
281387
_options = new()
282388
{
283389
// ObjectConverter behavior different in .NET8 most likely due to
@@ -289,10 +395,11 @@ private void InitializeObjects()
289395
new DatabaseObjectConverter(),
290396
new TypeConverter()
291397
}
398+
292399
};
293400

294401
_columnDefinition = GetColumnDefinition(typeof(string), DbType.String, true, false, false, new string("John"), false);
295-
_sourceDefinition = GetSourceDefinition(false, false, new List<string>() { "FirstName" }, _columnDefinition);
402+
_sourceDefinition = GetSourceDefinition(false, false, new List<string>() { columnName }, _columnDefinition);
296403

297404
_databaseTable = new DatabaseTable()
298405
{
@@ -311,10 +418,10 @@ private void InitializeObjects()
311418
{
312419
IsInsertDMLTriggerEnabled = false,
313420
IsUpdateDMLTriggerEnabled = false,
314-
PrimaryKey = new List<string>() { "FirstName" },
421+
PrimaryKey = new List<string>() { columnName },
315422
},
316423
};
317-
_databaseView.ViewDefinition.Columns.Add("FirstName", _columnDefinition);
424+
_databaseView.ViewDefinition.Columns.Add(columnName, _columnDefinition);
318425

319426
_parameterDefinition = new()
320427
{
@@ -331,10 +438,10 @@ private void InitializeObjects()
331438
SourceType = EntitySourceType.StoredProcedure,
332439
StoredProcedureDefinition = new()
333440
{
334-
PrimaryKey = new List<string>() { "FirstName" },
441+
PrimaryKey = new List<string>() { columnName },
335442
}
336443
};
337-
_databaseStoredProcedure.StoredProcedureDefinition.Columns.Add("FirstName", _columnDefinition);
444+
_databaseStoredProcedure.StoredProcedureDefinition.Columns.Add(columnName, _columnDefinition);
338445
_databaseStoredProcedure.StoredProcedureDefinition.Parameters.Add("Id", _parameterDefinition);
339446
}
340447

0 commit comments

Comments
 (0)