Skip to content

Commit 7d76286

Browse files
Fix the Serialization/Deserialization issue with $ prefix columns (#2944)
### Why make this change? Serialization and deserialization of metadata currently fail when column names are prefixed with the $ symbol. ### Root cause 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. ### What is this change? 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. ### How was this tested - [x] Unit tests --------- Co-authored-by: Aniruddh Munde <[email protected]>
1 parent 555faaa commit 7d76286

File tree

2 files changed

+172
-7
lines changed

2 files changed

+172
-7
lines changed

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

Lines changed: 77 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(IsSourceDefinitionProperty))
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,74 @@ 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+
Type propType = prop.PropertyType;
77+
78+
// Only escape columns for properties whose type is exactly SourceDefinition (not subclasses).
79+
// This is because, we do not want unnecessary mutation of subclasses of SourceDefinition unless needed.
80+
if (IsSourceDefinitionProperty(prop) && propVal is SourceDefinition sourceDef && propVal.GetType() == typeof(SourceDefinition))
81+
{
82+
EscapeDollaredColumns(sourceDef);
83+
}
84+
85+
JsonSerializer.Serialize(writer, propVal, propType, options);
6286
}
6387

6488
writer.WriteEndObject();
6589
}
6690

91+
private static bool IsSourceDefinitionProperty(PropertyInfo prop)
92+
{
93+
// Only return true for properties whose type is exactly SourceDefinition (not subclasses)
94+
return prop.PropertyType == typeof(SourceDefinition);
95+
}
96+
97+
/// <summary>
98+
/// Escapes column keys that start with '$' to '_$' for serialization.
99+
/// </summary>
100+
private static void EscapeDollaredColumns(SourceDefinition sourceDef)
101+
{
102+
if (sourceDef.Columns is null || sourceDef.Columns.Count == 0)
103+
{
104+
return;
105+
}
106+
107+
List<string> keysToEscape = sourceDef.Columns.Keys
108+
.Where(k => k.StartsWith(DOLLAR_CHAR, StringComparison.Ordinal))
109+
.ToList();
110+
111+
foreach (string key in keysToEscape)
112+
{
113+
ColumnDefinition col = sourceDef.Columns[key];
114+
sourceDef.Columns.Remove(key);
115+
string newKey = ESCAPED_DOLLARCHAR + key[1..];
116+
sourceDef.Columns[newKey] = col;
117+
}
118+
}
119+
120+
/// <summary>
121+
/// Unescapes column keys that start with '_$' to '$' for deserialization.
122+
/// </summary>
123+
private static void UnescapeDollaredColumns(SourceDefinition sourceDef)
124+
{
125+
if (sourceDef.Columns is null || sourceDef.Columns.Count == 0)
126+
{
127+
return;
128+
}
129+
130+
List<string> keysToUnescape = sourceDef.Columns.Keys
131+
.Where(k => k.StartsWith(ESCAPED_DOLLARCHAR, StringComparison.Ordinal))
132+
.ToList();
133+
134+
foreach (string key in keysToUnescape)
135+
{
136+
ColumnDefinition col = sourceDef.Columns[key];
137+
sourceDef.Columns.Remove(key);
138+
string newKey = DOLLAR_CHAR + key[11..];
139+
sourceDef.Columns[newKey] = col;
140+
}
141+
}
142+
67143
private static Type GetTypeFromName(string typeName)
68144
{
69145
Type? type = Type.GetType(typeName);

src/Service.Tests/UnitTests/SerializationDeserializationTests.cs

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

280-
private void InitializeObjects()
280+
/// <summary>
281+
/// Validates serialization and deserilization 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+
Dictionary<string, DatabaseObject> deserializedDict = JsonSerializer.Deserialize<Dictionary<string, DatabaseObject>>(serializedDict, _options)!;
303+
304+
DatabaseTable deserializedDatabaseTable = (DatabaseTable)deserializedDict["person"];
305+
306+
Assert.AreEqual(deserializedDatabaseTable.SourceType, _databaseTable.SourceType);
307+
Assert.AreEqual(deserializedDatabaseTable.FullName, _databaseTable.FullName);
308+
deserializedDatabaseTable.Equals(_databaseTable);
309+
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseTable.SourceDefinition, _databaseTable.SourceDefinition, "$FirstName");
310+
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseTable.TableDefinition, _databaseTable.TableDefinition, "$FirstName");
311+
}
312+
313+
/// <summary>
314+
/// Validates serialization and deserilization of Dictionary containing DatabaseView
315+
/// The table will have dollar sign prefix ($) in the column name
316+
/// this is how we serialize and deserialize metadataprovider.EntityToDatabaseObject dict.
317+
/// </summary>
318+
[TestMethod]
319+
public void TestDatabaseViewSerializationDeserialization_WithDollarColumn()
320+
{
321+
InitializeObjects(generateDollaredColumn: true);
322+
323+
TestTypeNameChanges(_databaseView, "DatabaseView");
324+
325+
// Test to catch if there is change in number of properties/fields
326+
// Note: On Addition of property make sure it is added in following object creation _databaseView and include in serialization
327+
// and deserialization test.
328+
int fields = typeof(DatabaseView).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).Length;
329+
Assert.AreEqual(fields, 6);
330+
331+
string serializedDatabaseView = JsonSerializer.Serialize(_databaseView, _options);
332+
DatabaseView deserializedDatabaseView = JsonSerializer.Deserialize<DatabaseView>(serializedDatabaseView, _options)!;
333+
334+
Assert.AreEqual(deserializedDatabaseView.SourceType, _databaseView.SourceType);
335+
deserializedDatabaseView.Equals(_databaseView);
336+
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseView.SourceDefinition, _databaseView.SourceDefinition, "$FirstName");
337+
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseView.ViewDefinition, _databaseView.ViewDefinition, "$FirstName");
338+
}
339+
340+
/// <summary>
341+
/// Validates serialization and deserilization of Dictionary containing DatabaseStoredProcedure
342+
/// The table will have dollar sign prefix ($) in the column name
343+
/// this is how we serialize and deserialize metadataprovider.EntityToDatabaseObject dict.
344+
/// </summary>
345+
[TestMethod]
346+
public void TestDatabaseStoredProcedureSerializationDeserialization_WithDollarColumn()
281347
{
348+
InitializeObjects(generateDollaredColumn: true);
349+
350+
TestTypeNameChanges(_databaseStoredProcedure, "DatabaseStoredProcedure");
351+
352+
// Test to catch if there is change in number of properties/fields
353+
// Note: On Addition of property make sure it is added in following object creation _databaseStoredProcedure and include in serialization
354+
// and deserialization test.
355+
int fields = typeof(DatabaseStoredProcedure).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).Length;
356+
Assert.AreEqual(fields, 6);
357+
358+
string serializedDatabaseSP = JsonSerializer.Serialize(_databaseStoredProcedure, _options);
359+
DatabaseStoredProcedure deserializedDatabaseSP = JsonSerializer.Deserialize<DatabaseStoredProcedure>(serializedDatabaseSP, _options)!;
360+
361+
Assert.AreEqual(deserializedDatabaseSP.SourceType, _databaseStoredProcedure.SourceType);
362+
deserializedDatabaseSP.Equals(_databaseStoredProcedure);
363+
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseSP.SourceDefinition, _databaseStoredProcedure.SourceDefinition, "$FirstName", true);
364+
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseSP.StoredProcedureDefinition, _databaseStoredProcedure.StoredProcedureDefinition, "$FirstName", true);
365+
}
366+
367+
private void InitializeObjects(bool generateDollaredColumn = false)
368+
{
369+
string columnName = generateDollaredColumn ? "$FirstName" : "FirstName";
282370
_options = new()
283371
{
284372
// ObjectConverter behavior different in .NET8 most likely due to
@@ -290,10 +378,11 @@ private void InitializeObjects()
290378
new DatabaseObjectConverter(),
291379
new TypeConverter()
292380
}
381+
293382
};
294383

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

298387
_databaseTable = new DatabaseTable()
299388
{
@@ -312,10 +401,10 @@ private void InitializeObjects()
312401
{
313402
IsInsertDMLTriggerEnabled = false,
314403
IsUpdateDMLTriggerEnabled = false,
315-
PrimaryKey = new List<string>() { "FirstName" },
404+
PrimaryKey = new List<string>() { columnName },
316405
},
317406
};
318-
_databaseView.ViewDefinition.Columns.Add("FirstName", _columnDefinition);
407+
_databaseView.ViewDefinition.Columns.Add(columnName, _columnDefinition);
319408

320409
_parameterDefinition = new()
321410
{
@@ -332,10 +421,10 @@ private void InitializeObjects()
332421
SourceType = EntitySourceType.StoredProcedure,
333422
StoredProcedureDefinition = new()
334423
{
335-
PrimaryKey = new List<string>() { "FirstName" },
424+
PrimaryKey = new List<string>() { columnName },
336425
}
337426
};
338-
_databaseStoredProcedure.StoredProcedureDefinition.Columns.Add("FirstName", _columnDefinition);
427+
_databaseStoredProcedure.StoredProcedureDefinition.Columns.Add(columnName, _columnDefinition);
339428
_databaseStoredProcedure.StoredProcedureDefinition.Parameters.Add("Id", _parameterDefinition);
340429
}
341430

0 commit comments

Comments
 (0)