Skip to content

Source generator

Sveinung edited this page Sep 7, 2024 · 7 revisions

Introduction

The source generator is useful for a typical use case where you would want to create rows in a worksheet based on a C# class (or struct). With this approach, properties of the type are mapped to row cells. This mapping code can be typed by hand, but there are some of the advantages of using the source generator:

  • The mapping code is generated automatically, saving you some key strokes. It also means that later changes to the C# type automatically updates the mapping code.
  • The generated mapping code can in some cases have better performance compared to manually typed mapping code. As one example, the generated code will use ArrayPool<T> to avoid some allocations.

Since the code is generated during compile-time, there is no additional penalty to runtime performance, as can be the case for e.g. reflection-based implementations.

Requirements

If you are using Visual Studio, you need to use Visual Studio 2022 version 17.9 or later. The C# version used in your project must also be 8.0 or greater.

Getting started

First of all, the source generator needs to know which C# type to generate code for, and it needs a class on which to put the generated code. Both are handled by declaring a context class.

In the examples we will create rows for this C# class:

namespace MyNamespace;

public class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string? MiddleName { get; set; }
}

The context class must:

  • Have the partial modifier.
  • Inherit WorksheetRowContext.
  • Have the WorksheetRow attribute with a type parameter.

Here is a context class for Person:

using SpreadCheetah.SourceGeneration;

namespace MyNamespace;

[WorksheetRow(typeof(Person))]
public partial class PersonContext : WorksheetRowContext;

Once this has been created, the source generator should automatically generate the implementation of PersonContext. The implementation will be created in another file in the same project. If you're curious, you should be able to see into that file by navigating to declarations of the context class in your IDE.

We are now ready to create worksheet rows. To do so we will call AddAsRowAsync and pass an instance of Person as well as the metadata type generated by the source generator. Here is an example:

await using var spreadsheet = await Spreadsheet.CreateNewAsync(stream);
await spreadsheet.StartWorksheetAsync("Sheet 1");

var person = new Person
{
    FirstName = "Ola",
    LastName = "Nordmann"
};

// This will use the code generated by the source generator
await spreadsheet.AddAsRowAsync(person, PersonContext.Default.Person);

await spreadsheet.FinishAsync();

If we have multiple instances of Person, we can also use Spreadsheet.AddRangeAsRowsAsync which accepts and IEnumerable<T>.

Column order

By default, the column order is determined by the property order in the C# type. The column order can be changed by reordering the properties, or by using the ColumnOrder attribute.

The ColumnOrder attribute can be placed on one or more of the properties. The properties are then ordered by the attribute values in ascending order. In the following example, the column order becomes FirstName, MiddleName, LastName:

public class Person
{
    [ColumnOrder(3)]
    public string LastName { get; set; }

    [ColumnOrder(1)]
    public string FirstName { get; set; }

    [ColumnOrder(2)]
    public string? MiddleName { get; set; }
}

The column order values decides the ordering between the columns, and the values don't need to match the actual column numbers in the worksheet. Two properties can not have the same column order value, and attempting to have that will lead to a build error.

Properties without the attribute will get a column order value implicitly. The implicit value will be the lowest number greater than 0 that has not been used on any other column.

The following snippet shows an example which will create the same column order as in the previous example (FirstName, MiddleName, LastName):

public class Person
{
    [ColumnOrder(10)]
    public string LastName { get; set; }

    [ColumnOrder(1)]
    public string FirstName { get; set; }

    public string? MiddleName { get; set; }
}

Here the MiddleName property implicitly gets a column order value of 2. Note that LastName will be in the third column, since column order values just decides the ordering, and not the actual column number.

Column header

A common use case is to first create a header row before creating rows with values. The source generator can be used to generate the header names as well. Spreadsheet has an AddHeaderRowAsync method that will add a row of header names to the worksheet. Similarly to AddAsRowAsync, the generated metadata type must be passed as an argument. An optional StyleId can also be passed as the second argument.

Here is an example:

await using var spreadsheet = await Spreadsheet.CreateNewAsync(stream);
await spreadsheet.StartWorksheetAsync("Sheet 1");

var style = new Style { Font = { Bold = true } };
var styleId = spreadsheet.AddStyle(style);

// This adds a row of header names generated by the source generator
await spreadsheet.AddHeaderRowAsync(PersonContext.Default.Person, styleId);

await spreadsheet.FinishAsync();

By default, the generated header names are based on the property names. You can use the ColumnHeader attribute to set your own header names.

Here is an example of that:

public class Person
{
    public string LastName { get; set; }

    [ColumnHeader("First name")]
    public string FirstName { get; set; }

    public string? MiddleName { get; set; }
}

You can also reference resource (.resx) files. For example if we have MyResources.resx containing key Header_FirstName, then we can use it like this:

public class Person
{
    public string LastName { get; set; }

    [ColumnHeader(typeof(MyResources), nameof(MyResources.Header_FirstName)]
    public string FirstName { get; set; }

    public string? MiddleName { get; set; }
}

If there are multiple translations for the resource, then similarly to a call to MyResources.Header_FirstName directly, the current culture will determine which translation gets used. Note that the resource file must have a public access modifier. This feature is not just limited to resource files, and can also similarly reference other public static properties.

Cell style

To have style on a cell when using the source generator, you need to create a named style on Spreadsheet, and then reference the style name in the CellStyle attribute. The named style must be created before the first call to AddAsRowAsync.

Here is an example of how to get the last name of Person in bold:

public class Person
{
    [CellStyle("My bold style")]
    public string LastName { get; set; }

    public string FirstName { get; set; }

    public string? MiddleName { get; set; }
}
await using var spreadsheet = await Spreadsheet.CreateNewAsync(stream);
await spreadsheet.StartWorksheetAsync("Sheet 1");

var style = new Style { Font = { Bold = true } };

// This adds a named style
spreadsheet.AddStyle(style, "My bold style");

var person = new Person
{
    FirstName = "Ola",
    LastName = "Nordmann"
};

await spreadsheet.AddAsRowAsync(person, PersonContext.Default.Person);

await spreadsheet.FinishAsync();

If the named style hadn't been added to Spreadsheet in the example above, then an exception would be thrown.

Cell value converter

If you need more flexibility in how property values are mapped to cells, then you can create an implementation of CellValueConverter<T> which can be used by the generated code. CellValueConverter<T> is an abstract class and derived classes must implement a method that takes the property value as input, and returns a DataCell. To instruct the source generator to use your implementation, the property must have the CellValueConverter attribute with a typeof operator.

Here is an example where we will show N/A for a person when the age is null:

public class Person
{
    public string LastName { get; set; }

    public string FirstName { get; set; }

    [CellValueConverter(typeof(AgeConverter))]
    public int? Age { get; set; }
}

public class AgeConverter : CellValueConverter<int?>
{
    public override DataCell ConvertToDataCell(int? age)
    {
        return age is null
            ? new DataCell("N/A")
            : new DataCell(age.Value);
    }
}
Clone this wiki locally