Skip to content

Commit 8caae38

Browse files
Fix Serialization/Deserialization issue with $ prefix columns (#3051) (#3137)
## Why make this change? Serialization and deserialization of metadata currently fail when column names are prefixed with the $ symbol. ## What is this change? This pull request enhances the serialization and deserialization logic for database metadata objects to properly handle column names that start with a dollar sign ($). It introduces a mechanism to escape such column names during serialization and unescape them during deserialization, ensuring compatibility and correctness when processing metadata with special column names. ## How was this tested? - [x] Unit Tests Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent b76c4a8 commit 8caae38

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
@@ -277,8 +277,114 @@ public void TestDictionaryDatabaseObjectSerializationDeserialization()
277277
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseTable.TableDefinition, _databaseTable.TableDefinition, "FirstName");
278278
}
279279

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

295402
_columnDefinition = GetColumnDefinition(typeof(string), DbType.String, true, false, false, new string("John"), false);
296-
_sourceDefinition = GetSourceDefinition(false, false, new List<string>() { "FirstName" }, _columnDefinition);
403+
_sourceDefinition = GetSourceDefinition(false, false, new List<string>() { columnName }, _columnDefinition);
297404

298405
_databaseTable = new DatabaseTable()
299406
{
@@ -312,10 +419,10 @@ private void InitializeObjects()
312419
{
313420
IsInsertDMLTriggerEnabled = false,
314421
IsUpdateDMLTriggerEnabled = false,
315-
PrimaryKey = new List<string>() { "FirstName" },
422+
PrimaryKey = new List<string>() { columnName },
316423
},
317424
};
318-
_databaseView.ViewDefinition.Columns.Add("FirstName", _columnDefinition);
425+
_databaseView.ViewDefinition.Columns.Add(columnName, _columnDefinition);
319426

320427
_parameterDefinition = new()
321428
{
@@ -332,10 +439,10 @@ private void InitializeObjects()
332439
SourceType = EntitySourceType.StoredProcedure,
333440
StoredProcedureDefinition = new()
334441
{
335-
PrimaryKey = new List<string>() { "FirstName" },
442+
PrimaryKey = new List<string>() { columnName },
336443
}
337444
};
338-
_databaseStoredProcedure.StoredProcedureDefinition.Columns.Add("FirstName", _columnDefinition);
445+
_databaseStoredProcedure.StoredProcedureDefinition.Columns.Add(columnName, _columnDefinition);
339446
_databaseStoredProcedure.StoredProcedureDefinition.Parameters.Add("Id", _parameterDefinition);
340447
}
341448

0 commit comments

Comments
 (0)