Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
- `c-select`: The `create` prop now supports a `position` property to control whether the create item appears at the start ('start', default) or end ('end') of the dropdown list.
- `c-select-many-to-many`: The `itemTitle` prop now receives the existing selected middle entity instance, if there is one.
- `c-loader-status`: Added `show-success` prop and flag to display success messages when operations complete successfully.
- Support for enums annotated with `[JsonConverter(typeof(JsonStringEnumConverter))]` to be serialized as strings in APIs while maintaining numeric enum behavior on the client-side through transparent conversion.

## Fixes
- Fix error in codegen when using JS reserved keywords or C# contextual keywords as parameter names.
Expand Down
16 changes: 16 additions & 0 deletions docs/modeling/model-components/properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ The following kinds of properties may be declared on your models.

Most common built-in primitive (numerics, strings, booleans) and other scalar data types (enums, [date types](/topics/working-with-dates.md), `Guid`, `Uri`), and their nullable variants, are all supported as model properties. Collections of these types are also supported.

#### Enum String Serialization

Enums can be serialized as strings in API responses by annotating them with `[JsonConverter(typeof(JsonStringEnumConverter))]`:

```c#
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Status
{
Active = 1,
Inactive = 2,
Pending = 3
}
```

When this attribute is present, the enum values will be sent as strings (e.g., `"Active"`) in JSON responses instead of numbers. The generated TypeScript enums and ViewModels continue to use numeric values on the client-side, with transparent conversion handled automatically by Coalesce's serialization layer.

### Non-mapped POCOs

Properties of a type that are not on your `DbContext` will also have corresponding properties generated on the [TypeScript ViewModels](/stacks/vue/layers/viewmodels.md#generated-members) typed as [Plain Models](/stacks/vue/layers/models.md), and the values of such properties will be sent with the object to the client when requested. Properties of this type will also be sent back to the server by the client when they are encountered.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ private void WriteEnumMetadata(TypeScriptCodeBuilder b, TypeViewModel model)
b.StringProp("name", model.ClientTypeName, asConst: true);
b.StringProp("displayName", model.DisplayName);
b.StringProp("type", "enum");

// Add flag for string serialization if this enum has JsonStringEnumConverter
if (model.IsEnumStringSerializable)
{
b.Prop("serializeAsString", "true");
}

string enumShape = string.Join("|", model.EnumValues.Select(ev => $"\"{ev.Name}\""));
b.Line($"...getEnumMeta<{enumShape}>([");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;

namespace IntelliTect.Coalesce.Tests.TargetClasses.TestDbContext;

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum StringSerializedEnum
{
[Display(Name = "First Value")]
FirstValue = 1,

[Display(Name = "Second Value")]
SecondValue = 2,

ThirdValue = 3
}

public enum RegularEnum
{
FirstValue = 1,
SecondValue = 2,
ThirdValue = 3
}

public class StringEnumModel
{
public int Id { get; set; }

public StringSerializedEnum StringEnum { get; set; }

public RegularEnum RegularEnum { get; set; }

public StringSerializedEnum? NullableStringEnum { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ public class AppDbContext : DbContext
public DbSet<MultipleParents> MultipleParents { get; set; }
public DbSet<Parent1> Parent1s { get; set; }
public DbSet<Parent2> Parent2s { get; set; }

public DbSet<StringEnumModel> StringEnumModels { get; set; }


public AppDbContext() : this(Guid.NewGuid().ToString()) { }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using IntelliTect.Coalesce.Tests.TargetClasses.TestDbContext;
using IntelliTect.Coalesce.Tests.Util;
using IntelliTect.Coalesce.TypeDefinition;
using Xunit;

namespace IntelliTect.Coalesce.Tests.Tests.TypeDefinition
{
public class EnumStringSerializationTests
{
public EnumStringSerializationTests()
{
ReflectionRepositoryFactory.Initialize();
}

[Fact]
public void IsEnumStringSerializable_WithJsonStringEnumConverter_ReturnsTrue()
{
// Arrange
var enumType = ReflectionRepository.Global.GetOrAddType(typeof(StringSerializedEnum));

// Act & Assert
Assert.True(enumType.IsEnum);
Assert.True(enumType.IsEnumStringSerializable);
}

[Fact]
public void IsEnumStringSerializable_WithoutJsonStringEnumConverter_ReturnsFalse()
{
// Arrange
var enumType = ReflectionRepository.Global.GetOrAddType(typeof(RegularEnum));

// Act & Assert
Assert.True(enumType.IsEnum);
Assert.False(enumType.IsEnumStringSerializable);
}

[Fact]
public void StringEnumModel_Properties_DetectCorrectSerializationType()
{
// Arrange
var classViewModel = ReflectionRepository.Global.GetClassViewModel<StringEnumModel>();

// Act
var stringEnumProperty = classViewModel.PropertyByName(nameof(StringEnumModel.StringEnum));
var regularEnumProperty = classViewModel.PropertyByName(nameof(StringEnumModel.RegularEnum));
var nullableStringEnumProperty = classViewModel.PropertyByName(nameof(StringEnumModel.NullableStringEnum));

// Assert
Assert.True(stringEnumProperty.Type.NullableValueUnderlyingType.IsEnumStringSerializable);
Assert.False(regularEnumProperty.Type.NullableValueUnderlyingType.IsEnumStringSerializable);
Assert.True(nullableStringEnumProperty.Type.NullableValueUnderlyingType.IsEnumStringSerializable);
}
}
}
8 changes: 8 additions & 0 deletions src/IntelliTect.Coalesce/TypeDefinition/TypeViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ internal TypeViewModel(ReflectionRepository? reflectionRepository) : this()

public abstract bool IsEnum { get; }

/// <summary>
/// Returns true if this enum type is annotated with JsonConverter(typeof(JsonStringEnumConverter))
/// and should be serialized as strings instead of numeric values.
/// </summary>
public bool IsEnumStringSerializable => IsEnum &&
this.HasAttribute<System.Text.Json.Serialization.JsonConverterAttribute>() &&
this.GetAttributeValue<System.Text.Json.Serialization.JsonConverterAttribute>(a => a.ConverterType)?.FullyQualifiedName == "System.Text.Json.Serialization.JsonStringEnumConverter";

public abstract TypeViewModel? FirstTypeArgument { get; }

public abstract TypeViewModel? ArrayType { get; }
Expand Down
6 changes: 6 additions & 0 deletions src/coalesce-vue/src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,12 @@ export interface EnumType<K extends string = string> extends Metadata {
*/
readonly values: EnumMember[];

/**
* True if this enum should be serialized as strings instead of numbers for JSON.
* This is set when the enum has JsonStringEnumConverter attribute.
*/
readonly serializeAsString?: boolean;

// TODO: support [Flags] enums? Would need special handling for displaying/editing, and a flag on the metadata to flag it as [Flags]
}

Expand Down
44 changes: 42 additions & 2 deletions src/coalesce-vue/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,6 @@ export function parseValue(

switch (meta.type) {
case "number":
case "enum":
if (type === "number") return value;

if (type !== "string") {
Expand All @@ -230,6 +229,36 @@ export function parseValue(
}
return parsed;

case "enum":
if (type === "number") return value;

if (type !== "string") {
// We don't want to parse things like booleans into numbers.
// Strings are all we should be handling.
throw parseError(value, meta);
}

// Parse empty/blank as null, not zero.
if (isNullOrWhitespace(value)) {
return null;
}

// If this enum is string-serialized and we have a string value,
// look up the corresponding numeric value
if (meta.typeDef.serializeAsString) {
const enumMember = meta.typeDef.valueLookup[value];
if (enumMember) {
return enumMember.value;
}
// If we can't find the string value, fall through to numeric parsing
}

const enumParsed = Number(value);
if (isNaN(enumParsed)) {
throw parseError(value, meta);
}
return enumParsed;

case "string":
if (type === "string") return value;
if (type === "object") throw parseError(value, meta);
Expand Down Expand Up @@ -691,7 +720,18 @@ class MapToDtoVisitor extends Visitor<
}

protected visitEnumValue(value: any, meta: EnumValue) {
return parseValue(value, meta);
const parsed = parseValue(value, meta);
if (parsed == null) return null;

// If this enum should be serialized as strings, convert the numeric value to string
if (meta.typeDef.serializeAsString) {
const enumMember = meta.typeDef.valueLookup[parsed];
if (enumMember) {
return enumMember.strValue;
}
}

return parsed;
}

protected visitUnknownValue(value: any, meta: UnknownValue) {
Expand Down
25 changes: 25 additions & 0 deletions src/test-targets/api-clients.g.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading