jmcnamara / rust_xlsxwriter

A Rust library for creating Excel XLSX files.
https://crates.io/crates/rust_xlsxwriter
Apache License 2.0
316 stars 25 forks source link

WIP: Custom derive macro for `rust_xlsxwriter` specific serialization options #66

Closed jmcnamara closed 7 months ago

jmcnamara commented 8 months ago

In a comment on #63 @claudiofsr said:

Another suggestion would be to choose to format the data structure like this:

use serde::{Serialize, Deserialize};

  // Create a deserializable/serializable test struct.
  #[derive(Deserialize, Serialize)]
  struct Produce {
      #[serde(rename = "Fruit FooBar")]
      #[rust_xlsxwriter(set_min_width=8)]
      fruit: &'static str,
      #[serde(rename = "Value")]
      #[rust_xlsxwriter(set_num_format="#,##0.00")] // 2 digits after the decimal point
      cost: f64,
      #[serde(rename = "Date DMY")]
      #[rust_xlsxwriter(set_num_format="dd/mm/yyyy")]
      dmy: Option<NaiveDate>,
      #[serde(rename = "Date MDY",)]
      #[rust_xlsxwriter(set_num_format="mm/dd/yyyy")]
      mdy: NaiveDate,
      #[serde(rename = "Long Description")]
      #[rust_xlsxwriter(set_min_width=8, set_max_width=60)]
      long_description: String
  }

I've started work on implementing a custom derive macro to enable this functionality on the derive branch.

Here is the feature set to be implemented

SerializeFieldOptions:

CustomSerializeField

Serde Container attributes:

Serde Field attributes:

Packaging:

As a start, you can now do this:

use derive::ExcelSerialize;
use rust_xlsxwriter::{ExcelSerialize, Workbook, XlsxError};
use serde::Serialize;

fn main() -> Result<(), XlsxError> {
    let mut workbook = Workbook::new();

    // Add a worksheet to the workbook.
    let worksheet = workbook.add_worksheet();

    #[derive(ExcelSerialize, Serialize)]
    struct Produce {
        fruit: &'static str,
        cost: f64,
        in_stock: bool,
    }

    // Create some data instances.
    let items = [
        Produce {
            fruit: "Peach",
            cost: 1.05,
            in_stock: true,
        },
        Produce {
            fruit: "Plum",
            cost: 0.15,
            in_stock: false,
        },
        Produce {
            fruit: "Pear",
            cost: 0.75,
            in_stock: true,
        },
    ];

    worksheet.set_serialize_headers::<Produce>(0, 0)?;
    worksheet.serialize(&items)?;

    // Save the file.
    workbook.save("serialize.xlsx")?;

    Ok(())
}

Output:

screenshot

Note the ExcelSerialize derived trait. The set_serialize_headers() doesn't use Serde serialization or deserialization (for the headers). Instead the macro generates code a custom impl for the type inline like this:

    impl ExcelSerialize for Produce {
        fn to_rust_xlsxwriter() -> rust_xlsxwriter::SerializeFieldOptions {
            let mut custom_headers: Vec<rust_xlsxwriter::CustomSerializeField> 
                = ::alloc::vec::Vec::new();
            custom_headers.push(rust_xlsxwriter::CustomSerializeField::new("fruit"));
            custom_headers.push(rust_xlsxwriter::CustomSerializeField::new("cost"));
            custom_headers.push(rust_xlsxwriter::CustomSerializeField::new("in_stock"));
            rust_xlsxwriter::SerializeFieldOptions::new()
                .set_struct_name("Produce")
                .set_custom_headers(&custom_headers)
        }
    }

It should be straight forward to support attributes like #[rust_xlsxwriter(set_num_format="dd/mm/yyyy")]. However, interacting (correctly) with Serde attributes will be a little trickier.

I'll continue to work on this for the next few weeks and post updates when there is enough functionality to try it out with a more realistic use case.

@lucatrv for information.

jmcnamara commented 8 months ago

@lucatrv I'll check when I'm online later but do the Result<> fields actually serialize with Serde. For example do they serialize to JSON? I don't see it as a handled type in the Serde data model and I don't remember it from the required serialization methods. Option<> is handled as separate actions for Some and None but I'm not sure if Result is handled at all. It might need a serialize_with helper function.

lucatrv commented 8 months ago

@jmcnamara, I confirm that the following works, but I thought you could add serialization support for Result<> within rust_xlsxwriter, now that I think twice about it, is it not possible because Result<> is not in the Serde model?

use rust_xlsxwriter::{Format, FormatBorder, Workbook, XlsxError};
use serde::{Serialize, Serializer, Deserialize};

fn main() -> Result<(), XlsxError> {
    let mut workbook = Workbook::new();

    // Add a worksheet to the workbook.
    let worksheet = workbook.add_worksheet();

    // Add some formats to use with the serialization data.
    let header_format = Format::new()
        .set_bold()
        .set_border(FormatBorder::Thin)
        .set_background_color("C6E0B4");

    // Create a serializable struct.
    #[derive(Deserialize, Serialize)]
    #[serde(rename_all = "PascalCase")]
    struct Student<'a> {
        name: &'a str,
        #[serde(serialize_with = "se_f64_or_string")]
        age: Result<f64, String>,
        #[serde(serialize_with = "se_f64_or_string")]
        id: Result<f64, String>,
    }

    fn se_f64_or_string<S>(
        f64_or_string: &Result<f64, String>,
        serializer: S,
    ) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        match f64_or_string {
            Ok(f64) => serializer.serialize_f64(*f64),
            Err(string) => serializer.serialize_str(string),
        }
    }

    let students = [
        Student {
            name: "Aoife",
            age: Ok(1.0),
            id: Err(String::from("564351")),
        },
        Student {
            name: "Caoimhe",
            age: Err(String::new()),
            id: Ok(443287.0),
        },
    ];

    // Set up the start location and headers of the data to be serialized.
    worksheet.deserialize_headers_with_format::<Student>(1, 3, &header_format)?;

    // Serialize the data.
    worksheet.serialize(&students)?;

    // Save the file.
    workbook.save("serialize.xlsx")?;

    Ok(())
}
jmcnamara commented 7 months ago

now that I think twice about it, is it not possible because Result<> is not in the Serde model?

Correct. rust_xslxwriter doesn't even see it during serialization (without a specific handler).

jmcnamara commented 7 months ago

Folks, the derive/attribute feature is now released in v0.61.0 on crates.io. Thanks for all the input, testing and suggestions.

See the updates docs at:

https://docs.rs/rust_xlsxwriter/latest/rust_xlsxwriter/serializer/index.html#setting-serialization-headers https://docs.rs/rust_xlsxwriter/latest/rust_xlsxwriter/serializer/index.html#controlling-excel-output-via-xlsxserialize-and-struct-attributes

lucatrv commented 7 months ago

Correct. rust_xslxwriter doesn't even see it during serialization (without a specific handler).

I thought it could be seen as a Serde enum.

jmcnamara commented 7 months ago

I thought it could be seen as a Serde enum.

@lucatrv You are right, it is handled by Serde but not rust_xlsxwriter since it currently ignores all enum types. However, this particular type which Serde refers to as a "newtype variant" could, and probably should, be handled. If you open a new bug report for that with your first example (https://github.com/jmcnamara/rust_xlsxwriter/issues/66#issuecomment-1887684026) I push a fix for it.

jmcnamara commented 7 months ago

rust_xlsxwriter serialization mini-update. I've added support for adding worksheet Tables to serialized areas.

Note the #[xlsx(table_default)] attribute in the following example. There is also #[xlsx(table_style = TableStyle::value)] and #[xlsx(table = Table::new())].

This is currently on main. I'll release it by or on the weekend.

use rust_xlsxwriter::{Workbook, XlsxError, XlsxSerialize};
use serde::Serialize;

fn main() -> Result<(), XlsxError> {
    let mut workbook = Workbook::new();

    // Add a worksheet to the workbook.
    let worksheet = workbook.add_worksheet();

    // Create a serializable struct.
    #[derive(XlsxSerialize, Serialize)]
    #[xlsx(table_default)]
    struct Produce {
        fruit: &'static str,
        cost: f64,
    }

    // Create some data instances.
    let items = [
        Produce {
            fruit: "Peach",
            cost: 1.05,
        },
        Produce {
            fruit: "Plum",
            cost: 0.15,
        },
        Produce {
            fruit: "Pear",
            cost: 0.75,
        },
    ];

    // Set the serialization location and headers.
    worksheet.set_serialize_headers::<Produce>(0, 0)?;

    // Serialize the data.
    worksheet.serialize(&items)?;

    // Save the file to disk.
    workbook.save("serialize.xlsx")?;

    Ok(())
}

Output:

screenshot