diff --git a/CHANGELOG.md b/CHANGELOG.md index 844d525..4c570ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +**Added** + +- `Scribe.Encoder` protocol to derive a list of table headers from a struct. + +**Changed** + +- Bumped minimum Elixir version to 1.14. +- More descriptive exception message when generating a table for a non-map/struct. +- Better organization of documentation. + ## v0.11.0 - 2024-08-31 **Added** diff --git a/LICENSE b/LICENSE index 2540342..66abbd6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2016-2024 Codedge LLC (https://www.codedge.io/) +Copyright (c) 2016-2025 Codedge LLC (https://www.codedge.io/) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 786f0a8..c3cba5d 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,6 @@ Git commit subjects use the [Karma style](http://karma-runner.github.io/5.0/dev/ ## License -Copyright (c) 2016-2024 Codedge LLC (https://www.codedge.io/) +Copyright (c) 2016-2025 Codedge LLC (https://www.codedge.io/) This library is MIT licensed. See the [LICENSE](https://github.com/codedge-llc/scribe/blob/main/LICENSE) for details. diff --git a/lib/scribe.ex b/lib/scribe.ex index 7b72f7d..c90639e 100644 --- a/lib/scribe.ex +++ b/lib/scribe.ex @@ -280,8 +280,7 @@ defmodule Scribe do defp fetch_keys(map) do map - |> Map.keys() - |> Enum.sort() + |> Scribe.Encoder.headers() |> process_headers() end end diff --git a/lib/scribe/encoder.ex b/lib/scribe/encoder.ex new file mode 100644 index 0000000..f151f8d --- /dev/null +++ b/lib/scribe/encoder.ex @@ -0,0 +1,98 @@ +defprotocol Scribe.Encoder do + @moduledoc """ + A protocol for custom Scribe encoding of data structures. + + If you have a struct, you can derive the implementation of this protocol + by specifying which fields should be encoded to Scribe: + + @derive {Scribe.Encoder, only: [....]} + defstruct ... + + It is also possible to encode all fields or skip some fields via the + `:except` option: + + @derive Scribe.Encoder + defstruct ... + + > #### Leaking Private Information {: .error} + > + > The `:except` approach should be used carefully to avoid + > accidentally leaking private information when new fields are added. + + Finally, if you don't own the struct you want to encode to Scribe, + you may use `Protocol.derive/3` placed outside of any module: + + Protocol.derive(Scribe.Encoder, NameOfTheStruct, only: [...]) + Protocol.derive(Scribe.Encoder, NameOfTheStruct) + """ + + @impl true + defmacro __deriving__(module, opts) do + fields = module |> Macro.struct_info!(__CALLER__) |> Enum.map(& &1.field) + fields = fields_to_encode(fields, opts) + + quote do + defimpl Scribe.Encoder, for: unquote(module) do + def headers(term) do + unquote(fields) + end + end + end + end + + defp fields_to_encode(fields, opts) do + cond do + only = Keyword.get(opts, :only) -> only(only, fields) + except = Keyword.get(opts, :except) -> except(except, fields) + true -> fields -- [:__struct__] + end + end + + defp only(only, fields) do + case only -- fields do + [] -> only + error_keys -> raise ArgumentError, error_msg(error_keys, fields, :only) + end + end + + defp except(except, fields) do + case except -- fields do + [] -> + fields -- [:__struct__ | except] + + error_keys -> + raise ArgumentError, error_msg(error_keys, fields, :except) + end + end + + defp error_msg(error_keys, fields, option) do + "unknown struct fields #{inspect(error_keys)} specified in :#{option}. Expected one of: " <> + "#{inspect(fields -- [:__struct__])}" + end + + @doc """ + A function invoked to derive a list of headers from given term. + """ + @fallback_to_any true + def headers(term) +end + +defimpl Scribe.Encoder, for: Map do + def headers(value) do + value + |> Map.keys() + |> Enum.sort() + end +end + +defimpl Scribe.Encoder, for: Any do + def headers(value) when is_struct(value) do + value + |> Map.keys() + |> Enum.sort() + end + + def headers(value) do + raise ArgumentError, "expected a map or struct, got: #{inspect(value)}" + end +end diff --git a/mix.exs b/mix.exs index 97a56c7..3947313 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule Scribe.Mixfile do use Mix.Project @source_url "https://github.com/codedge-llc/scribe" - @version "0.11.0" + @version "0.12.0" def project do [ @@ -11,7 +11,8 @@ defmodule Scribe.Mixfile do deps: deps(), docs: docs(), dialyzer: dialyzer(), - elixir: "~> 1.13", + elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), name: "Scribe", package: package(), start_permanent: Mix.env() == :prod, @@ -19,6 +20,10 @@ defmodule Scribe.Mixfile do ] end + defp elixirc_paths(:dev), do: ["lib", "test/support"] + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + def application do [extra_applications: [:logger, :pane]] end @@ -46,6 +51,19 @@ defmodule Scribe.Mixfile do LICENSE: [title: "License"] ], formatters: ["html"], + groups_for_modules: [ + "Available Styles": [ + Scribe.Style.Default, + Scribe.Style.GithubMarkdown, + Scribe.Style.NoBorder, + Scribe.Style.Pseudo, + Scribe.Style.Psql + ], + "Custom Styles": [ + Scribe.Border, + Scribe.Style + ] + ], main: "Scribe", skip_undefined_reference_warnings_on: ["CHANGELOG.md"], source_ref: "v#{@version}", diff --git a/test/support/user.ex b/test/support/user.ex new file mode 100644 index 0000000..5b58b20 --- /dev/null +++ b/test/support/user.ex @@ -0,0 +1,8 @@ +defmodule Scribe.User do + @moduledoc """ + Example struct implementing the `Scribe.Encoder` protocol. + """ + + @derive {Scribe.Encoder, except: [:inserted_at, :updated_at]} + defstruct [:id, :username, :email, :inserted_at, :updated_at] +end