Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ namespace Azure.DataApiBuilder.Core.Services.MetadataProviders.Converters
public class DatabaseObjectConverter : JsonConverter<DatabaseObject>
{
private const string TYPE_NAME = "TypeName";
private const string DOLLAR_CHAR = "$";

// ``DAB_ESCAPE$`` is used to escape column names that start with `$` during serialization.
// It is chosen to be unique enough to avoid collisions with actual column names.
private const string ESCAPED_DOLLARCHAR = "DAB_ESCAPE$";

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

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

foreach (PropertyInfo prop in objA.GetType().GetProperties().Where(IsSourceDefinitionOrDerivedClassProperty))
{
SourceDefinition? sourceDef = (SourceDefinition?)prop.GetValue(objA);
if (sourceDef is not null)
{
UnescapeDollaredColumns(sourceDef);
}
}

return objA;
}
}
Expand Down Expand Up @@ -58,12 +72,72 @@ public override void Write(Utf8JsonWriter writer, DatabaseObject value, JsonSeri
}

writer.WritePropertyName(prop.Name);
JsonSerializer.Serialize(writer, prop.GetValue(value), options);
object? propVal = prop.GetValue(value);

// Only escape columns for properties whose type(derived type) is SourceDefinition.
if (IsSourceDefinitionOrDerivedClassProperty(prop) && propVal is SourceDefinition sourceDef)
{
EscapeDollaredColumns(sourceDef);
}

JsonSerializer.Serialize(writer, propVal, options);
}

writer.WriteEndObject();
}

private static bool IsSourceDefinitionOrDerivedClassProperty(PropertyInfo prop)
{
// Return true for properties whose type is SourceDefinition or any class derived from SourceDefinition
return typeof(SourceDefinition).IsAssignableFrom(prop.PropertyType);
}

/// <summary>
/// Escapes column keys that start with '$' by prefixing them with 'DAB_ESCAPE$' for serialization.
/// </summary>
private static void EscapeDollaredColumns(SourceDefinition sourceDef)
{
if (sourceDef.Columns is null || sourceDef.Columns.Count == 0)
{
return;
}

List<string> keysToEscape = sourceDef.Columns.Keys
.Where(k => k.StartsWith(DOLLAR_CHAR, StringComparison.Ordinal))
.ToList();

foreach (string key in keysToEscape)
{
ColumnDefinition col = sourceDef.Columns[key];
sourceDef.Columns.Remove(key);
string newKey = ESCAPED_DOLLARCHAR + key[1..];
sourceDef.Columns[newKey] = col;
}
}

/// <summary>
/// Unescapes column keys that start with 'DAB_ESCAPE$' by removing the prefix and restoring the original '$' for deserialization.
/// </summary>
private static void UnescapeDollaredColumns(SourceDefinition sourceDef)
{
if (sourceDef.Columns is null || sourceDef.Columns.Count == 0)
{
return;
}

List<string> keysToUnescape = sourceDef.Columns.Keys
.Where(k => k.StartsWith(ESCAPED_DOLLARCHAR, StringComparison.Ordinal))
.ToList();

foreach (string key in keysToUnescape)
{
ColumnDefinition col = sourceDef.Columns[key];
sourceDef.Columns.Remove(key);
string newKey = DOLLAR_CHAR + key[ESCAPED_DOLLARCHAR.Length..];
sourceDef.Columns[newKey] = col;
Comment on lines 97 to 137
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The escaping logic only handles the Columns dictionary but does not handle the PrimaryKey list in SourceDefinition. If a column name starting with '$' is part of the PrimaryKey list, it will not be escaped/unescaped during serialization/deserialization, leading to inconsistency. The PrimaryKey list should also be processed to escape/unescape column names that start with '$'.

Suggested change
/// </summary>
private static void EscapeDollaredColumns(SourceDefinition sourceDef)
{
if (sourceDef.Columns is null || sourceDef.Columns.Count == 0)
{
return;
}
List<string> keysToEscape = sourceDef.Columns.Keys
.Where(k => k.StartsWith(DOLLAR_CHAR, StringComparison.Ordinal))
.ToList();
foreach (string key in keysToEscape)
{
ColumnDefinition col = sourceDef.Columns[key];
sourceDef.Columns.Remove(key);
string newKey = ESCAPED_DOLLARCHAR + key[1..];
sourceDef.Columns[newKey] = col;
}
}
/// <summary>
/// Unescapes column keys that start with '_$' to '$' for deserialization.
/// </summary>
private static void UnescapeDollaredColumns(SourceDefinition sourceDef)
{
if (sourceDef.Columns is null || sourceDef.Columns.Count == 0)
{
return;
}
List<string> keysToUnescape = sourceDef.Columns.Keys
.Where(k => k.StartsWith(ESCAPED_DOLLARCHAR, StringComparison.Ordinal))
.ToList();
foreach (string key in keysToUnescape)
{
ColumnDefinition col = sourceDef.Columns[key];
sourceDef.Columns.Remove(key);
string newKey = DOLLAR_CHAR + key[11..];
sourceDef.Columns[newKey] = col;
/// Also ensures that any matching entries in the PrimaryKey list are escaped
/// in the same way to keep them consistent with the Columns dictionary.
/// </summary>
private static void EscapeDollaredColumns(SourceDefinition sourceDef)
{
if (sourceDef.Columns is null || sourceDef.Columns.Count == 0)
{
// Even if there are no columns to escape, we still want to allow
// PrimaryKey to be processed if needed, so do not return early here.
}
else
{
List<string> keysToEscape = sourceDef.Columns.Keys
.Where(k => k.StartsWith(DOLLAR_CHAR, StringComparison.Ordinal))
.ToList();
foreach (string key in keysToEscape)
{
ColumnDefinition col = sourceDef.Columns[key];
sourceDef.Columns.Remove(key);
string newKey = ESCAPED_DOLLARCHAR + key[1..];
sourceDef.Columns[newKey] = col;
}
}
// Update PrimaryKey entries to use the escaped names as well.
if (sourceDef.PrimaryKey is not null && sourceDef.PrimaryKey.Count > 0)
{
for (int i = 0; i < sourceDef.PrimaryKey.Count; i++)
{
string pkColumn = sourceDef.PrimaryKey[i];
if (pkColumn is not null &&
pkColumn.StartsWith(DOLLAR_CHAR, StringComparison.Ordinal))
{
sourceDef.PrimaryKey[i] = ESCAPED_DOLLARCHAR + pkColumn[1..];
}
}
}
}
/// <summary>
/// Unescapes column keys that start with '_$' to '$' for deserialization.
/// Also ensures that any matching entries in the PrimaryKey list are unescaped
/// in the same way to keep them consistent with the Columns dictionary.
/// </summary>
private static void UnescapeDollaredColumns(SourceDefinition sourceDef)
{
if (sourceDef.Columns is null || sourceDef.Columns.Count == 0)
{
// Even if there are no columns to unescape, we still want to allow
// PrimaryKey to be processed if needed, so do not return early here.
}
else
{
List<string> keysToUnescape = sourceDef.Columns.Keys
.Where(k => k.StartsWith(ESCAPED_DOLLARCHAR, StringComparison.Ordinal))
.ToList();
foreach (string key in keysToUnescape)
{
ColumnDefinition col = sourceDef.Columns[key];
sourceDef.Columns.Remove(key);
string newKey = DOLLAR_CHAR + key[11..];
sourceDef.Columns[newKey] = col;
}
}
// Update PrimaryKey entries to use the unescaped names as well.
if (sourceDef.PrimaryKey is not null && sourceDef.PrimaryKey.Count > 0)
{
for (int i = 0; i < sourceDef.PrimaryKey.Count; i++)
{
string pkColumn = sourceDef.PrimaryKey[i];
if (pkColumn is not null &&
pkColumn.StartsWith(ESCAPED_DOLLARCHAR, StringComparison.Ordinal))
{
sourceDef.PrimaryKey[i] = DOLLAR_CHAR + pkColumn[ESCAPED_DOLLARCHAR.Length..];
}
}

Copilot uses AI. Check for mistakes.
}
}
Comment on lines 121 to 139
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unescaping logic only handles the Columns dictionary but does not handle the PrimaryKey list in SourceDefinition. If a column name starting with '$' is part of the PrimaryKey list, it will not be escaped/unescaped during serialization/deserialization, leading to inconsistency. The PrimaryKey list should also be processed to escape/unescape column names that start with '$'.

Copilot uses AI. Check for mistakes.

private static Type GetTypeFromName(string typeName)
{
Type? type = Type.GetType(typeName);
Expand Down
119 changes: 113 additions & 6 deletions src/Service.Tests/UnitTests/SerializationDeserializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,114 @@ public void TestDictionaryDatabaseObjectSerializationDeserialization()
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseTable.TableDefinition, _databaseTable.TableDefinition, "FirstName");
}

private void InitializeObjects()
/// <summary>
/// Validates serialization and deserialization of Dictionary containing DatabaseTable
/// The table will have dollar sign prefix ($) in the column name
/// this is how we serialize and deserialize metadataprovider.EntityToDatabaseObject dict.
/// </summary>
[TestMethod]
public void TestDictionaryDatabaseObjectSerializationDeserialization_WithDollarColumn()
{
InitializeObjects(generateDollaredColumn: true);

_options = new()
{
Converters = {
new DatabaseObjectConverter(),
new TypeConverter()
},
ReferenceHandler = ReferenceHandler.Preserve
};

Dictionary<string, DatabaseObject> dict = new() { { "person", _databaseTable } };

string serializedDict = JsonSerializer.Serialize(dict, _options);
// Assert that the serialized JSON contains the escaped dollar sign in column name
Assert.IsTrue(serializedDict.Contains("DAB_ESCAPE$FirstName"),
"Serialized JSON should contain the dollar-prefixed column name in SourceDefinition's Columns.");

Dictionary<string, DatabaseObject> deserializedDict = JsonSerializer.Deserialize<Dictionary<string, DatabaseObject>>(serializedDict, _options)!;
DatabaseTable deserializedDatabaseTable = (DatabaseTable)deserializedDict["person"];

Assert.AreEqual(deserializedDatabaseTable.SourceType, _databaseTable.SourceType);
Assert.AreEqual(deserializedDatabaseTable.FullName, _databaseTable.FullName);
deserializedDatabaseTable.Equals(_databaseTable);
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseTable.SourceDefinition, _databaseTable.SourceDefinition, "$FirstName");
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseTable.TableDefinition, _databaseTable.TableDefinition, "$FirstName");
}

/// <summary>
/// Validates serialization and deserialization of Dictionary containing DatabaseView
/// The table will have dollar sign prefix ($) in the column name
/// this is how we serialize and deserialize metadataprovider.EntityToDatabaseObject dict.
/// </summary>
[TestMethod]
public void TestDatabaseViewSerializationDeserialization_WithDollarColumn()
{
InitializeObjects(generateDollaredColumn: true);

TestTypeNameChanges(_databaseView, "DatabaseView");

Dictionary<string, DatabaseObject> dict = new();
dict.Add("person", _databaseView);

// Test to catch if there is change in number of properties/fields
// Note: On Addition of property make sure it is added in following object creation _databaseView and include in serialization
// and deserialization test.
int fields = typeof(DatabaseView).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).Length;
Assert.AreEqual(fields, 6);

string serializedDatabaseView = JsonSerializer.Serialize(dict, _options);
// Assert that the serialized JSON contains the escaped dollar sign in column name
Assert.IsTrue(serializedDatabaseView.Contains("DAB_ESCAPE$FirstName"),
"Serialized JSON should contain the dollar-prefixed column name in SourceDefinition's Columns.");
Dictionary<string, DatabaseObject> deserializedDict = JsonSerializer.Deserialize<Dictionary<string, DatabaseObject>>(serializedDatabaseView, _options)!;

DatabaseView deserializedDatabaseView = (DatabaseView)deserializedDict["person"];

Assert.AreEqual(deserializedDatabaseView.SourceType, _databaseView.SourceType);
deserializedDatabaseView.Equals(_databaseView);
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseView.SourceDefinition, _databaseView.SourceDefinition, "$FirstName");
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseView.ViewDefinition, _databaseView.ViewDefinition, "$FirstName");
}

/// <summary>
/// Validates serialization and deserialization of Dictionary containing DatabaseStoredProcedure
/// The table will have dollar sign prefix ($) in the column name
/// this is how we serialize and deserialize metadataprovider.EntityToDatabaseObject dict.
/// </summary>
[TestMethod]
public void TestDatabaseStoredProcedureSerializationDeserialization_WithDollarColumn()
{
InitializeObjects(generateDollaredColumn: true);

TestTypeNameChanges(_databaseStoredProcedure, "DatabaseStoredProcedure");

Dictionary<string, DatabaseObject> dict = new();
dict.Add("person", _databaseStoredProcedure);

// Test to catch if there is change in number of properties/fields
// Note: On Addition of property make sure it is added in following object creation _databaseStoredProcedure and include in serialization
// and deserialization test.
int fields = typeof(DatabaseStoredProcedure).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).Length;
Assert.AreEqual(fields, 6);

string serializedDatabaseSP = JsonSerializer.Serialize(dict, _options);
// Assert that the serialized JSON contains the escaped dollar sign in column name
Assert.IsTrue(serializedDatabaseSP.Contains("DAB_ESCAPE$FirstName"),
"Serialized JSON should contain the dollar-prefixed column name in SourceDefinition's Columns.");
Dictionary<string, DatabaseObject> deserializedDict = JsonSerializer.Deserialize<Dictionary<string, DatabaseObject>>(serializedDatabaseSP, _options)!;
DatabaseStoredProcedure deserializedDatabaseSP = (DatabaseStoredProcedure)deserializedDict["person"];

Assert.AreEqual(deserializedDatabaseSP.SourceType, _databaseStoredProcedure.SourceType);
deserializedDatabaseSP.Equals(_databaseStoredProcedure);
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseSP.SourceDefinition, _databaseStoredProcedure.SourceDefinition, "$FirstName", true);
VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseSP.StoredProcedureDefinition, _databaseStoredProcedure.StoredProcedureDefinition, "$FirstName", true);
}

private void InitializeObjects(bool generateDollaredColumn = false)
{
string columnName = generateDollaredColumn ? "$FirstName" : "FirstName";
_options = new()
{
// ObjectConverter behavior different in .NET8 most likely due to
Expand All @@ -289,10 +395,11 @@ private void InitializeObjects()
new DatabaseObjectConverter(),
new TypeConverter()
}

};

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

_databaseTable = new DatabaseTable()
{
Expand All @@ -311,10 +418,10 @@ private void InitializeObjects()
{
IsInsertDMLTriggerEnabled = false,
IsUpdateDMLTriggerEnabled = false,
PrimaryKey = new List<string>() { "FirstName" },
PrimaryKey = new List<string>() { columnName },
},
};
_databaseView.ViewDefinition.Columns.Add("FirstName", _columnDefinition);
_databaseView.ViewDefinition.Columns.Add(columnName, _columnDefinition);

_parameterDefinition = new()
{
Expand All @@ -331,10 +438,10 @@ private void InitializeObjects()
SourceType = EntitySourceType.StoredProcedure,
StoredProcedureDefinition = new()
{
PrimaryKey = new List<string>() { "FirstName" },
PrimaryKey = new List<string>() { columnName },
}
};
_databaseStoredProcedure.StoredProcedureDefinition.Columns.Add("FirstName", _columnDefinition);
_databaseStoredProcedure.StoredProcedureDefinition.Columns.Add(columnName, _columnDefinition);
_databaseStoredProcedure.StoredProcedureDefinition.Parameters.Add("Id", _parameterDefinition);
}

Expand Down