Skip to content

seeruk/morph

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

116 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Morph

A Go tool for automatically generating mapping code for similar types.

Morph is a CLI tool, but can also be used as a library, to be integrated into other applications as part of more complex code-generation pipelines.

Morph generates functions like this:

func MapFromRecipeToToRecipe(source *from.Recipe) to.Recipe {
  if source == nil {
    return to.Recipe{}
  }
  var target to.Recipe
  target.ID = to.RecipeID(source.RecipeId)
  target.Name = source.Name
  target.Servings = int(source.Servings)
  return target
}

Morph can map structs and enums, including fields containing basic types, pointers, slices, arrays, maps, nested structs, and concrete generic containers, using direct assignment, safe or configured type conversions, generated nested mappers, and user-provided callables.

Why Morph?

Using certain libraries and tools can mean you to end up with what are essentially duplicated types. For example, the ProtoBuf compiler doesn't generate idiomatic Go code, so you may want to represent the same types with idiomatic Go code (e.g. using time.Time, with correct initialism in field names, so on), or maybe you have a database library which uses code-gen.

Morph exists to attempt to alleviate the burden of writing boring, error-prone, time-consuming manual mapping code for these types.

Quick Start

Install Morph using the Go toolchain:

$ go install github.com/seeruk/morph/cmd/morph@latest

Morph requires a configuration file to get started. You can find more about that in the Configuration Overview section below.

Once you have a valid configuration file, you can run Morph.

If you have a morph.yaml in the same folder:

$ morph

This is equivalent to the explicit generate command:

$ morph generate

If you want to point Morph at a specific configuration file:

$ morph -config path/to/config.yml

You can preview what Morph would write without changing files:

$ morph --dry-run
$ morph generate --dry-run

Morph plans and generates code based on where the config file is. The configuration file must be within a Go module.

Configuration Overview

Morph requires a configuration file to function. It does not support taking parameters as flags. A very basic configuration file to map between a few types in a couple of packages could look like this:

# yaml-language-server: $schema=https://raw.githubusercontent.com/seeruk/morph/main/schemas/config.schema.json
packages:
- source: example.com/foodplanner/foodpb
  target: example.com/foodplanner/food
  types:
  - name: Recipe
  - name: Ingredient
  - source: Difficulty
    target: RecipeDifficulty

Configuration allows you to control quite a lot about how mapping works, what is generated, where it gets generated, and what other resources Morph can draw on.

The following sections cover other config sections, and following that are some other common "recipes" for things you might want to be able to do with Morph.

Defaults Hierarchy

Defaults Hierarchy

Morph configuration is layered, allowing you to specify defaults, and subsequently override them at more granular levels. Morph aims to be an unopinionated tool with sensible defaults. Top-level defaults are specified in the defaults section of the configuration.

The order of preference is:

  1. Property-level config
  2. Type-level config
  3. Type preset
  4. Package-level config
  5. Package preset
  6. Top-level default config
  7. Morph built-in defaults

It's worth noting, configuration on a package, type, or property level does not trickle down to nested mapping functions that Morph generates automatically. If you need Morph to make a customized mapper, it must be specified in the config file, or use top-level defaults.

Conversions

Conversions

Morph supports generating type conversions between basic types when it's safe to do so. This behaviour can be extended through configuration, allowing unsafe basic conversions, and allowing custom types to be converted if their underlying type supports it.

Conversions are configured at the top-level in configuration:

conversions:
- source: int
  targets:
  - int64
  - uint64
  bidirectional: true
- source: StringBasedID
  targets:
  - string

As conversions are global configuration, you might find there are scenarios where you want to disable them for certain packages, types, or properties. This can be done at any of these levels like so:

packages:
- source: example.com/source
  target: example.com/target
  conversions:
    enabled: false # Disable for this package pair.
  types:
  - name: Example
    conversions:
      enabled: true # Re-enable for this type pair.
    struct:
      properties:
      - name: LegacyID
        conversions:
          enabled: false # Disable again for this property.
Discovery

Discovery

Morph supports automatically finding and using potentially compatible mapping functions. This functionality is separate from explicitly asking Morph to use callables for mapping, and allows Morph to automatically use functions from explicitly listed packages, like so:

discovery:
  packages:
  - github.com/example/mappers/datetime
  - github.com/example/mappers/numeric
  exclusions:
  - github.com/example/mappers/numeric.IntToInt64

Exclusions can be provided to prevent Morph from using specific functions discovered in these packages, which can be useful if there are many potential functions, and not all of them are actually intended for use as mapping functions.

Callables

Callables

What are Callables?

Callables are functions or methods that can be explicitly referenced in the config file for Morph to potentially use for mapping, instead of Morph generated the mapping itself. There are 2 main kinds of callables:

Plain Callables

Plain callables are simple functions which take a source type and return a target type. These callables can error, and if they do, that errability will propagate up to the parent mapper it's used in, and so on.

func FooToBar(foo Foo) Bar
func FooToBarE(foo Foo) (Bar, error)

Morph does also support generic callables, as long as they're used on matching concrete types. For example. You might have an Optional[T any] and a Nullable[T any], and they might be used on a source field like Foo Optional[string] to Foo Nullable[string] - this is fine, and works pretty much the same as above:

func OptionalToNullable[T any](o Optional[T]) Nullable[T]
func OptionalToNullable[T any](o Optional[T]) (Nullable[T], error)

There are potential generic cases where Morph cannot use these functions though, for example, if the type arguments differ on the source and target type (Foo Optional[Bar] to Foo Nullable[Qux]). In this case, Morph wouldn't be able to map the inner type argument, it has no way to control it. For these kinds of cases, you can use a combinator callable.

Combinator Callables

Combinator callables allow you to provide callables to Morph which can be used to handle many generic types. They look like this:

func OptionalToNullable[I, O any](o Optional[I], mapFn func(I) O) Nullable[O]
func OptionalToNullableE[I, O any](o Optional[I], mapFn func(I) (O, error)) (Nullable[O], error)

Morph can pass mapping functions it uses, or generates, or can generate inline mapping functions to pass to these callables. If there are multiple type parameters, Morph expects a mapping function argument on the callable for each type parameter on the source/target type; for example, for an Either[L, R any] to Tuple[A, B] conversion, you could have:

func EitherToTuple[L, R, A, B any](
    e Either[L, R],
    mapLeft func(L) A,
    mapRight mapRight func(R) B,
) Tuple[A, B]

The mapping functions should look like plain callables, and each mapping function argument may return an error.

Configuring Callables

The aforementioned discovery is only for auto-discovery of entire packages worth of functions, for other callables to be used by Morph, you must specify them explicitly. Discovery is a nice way to include packages designed specifically for mapping, but you could end up pulling in way more than you want. Also, discovery is not scoped.

Explicitly configuring callables is the solution to both of those issues. Similar to other configuration options, you can configure callables in defaults, presets, on packages, on types, and on specific properties. Configuration looks something like this:

packages:
- source: example.com/foodplanner/foodpb
  target: example.com/foodplanner/food
  types:
  - name: Recipe
    callables:
    - google.golang.org/protobuf/types/known/timestamppb.Timestamp.AsTime
    - google.golang.org/protobuf/types/known/timestamppb.New

In the above example, since this is specified at the type level, these functions can be used by Morph for any property's value mapping. It will not trickle down to nested mappings.

Scoped callables are prioritized by where they are configured: type callables are tried before type preset callables, then package callables, package preset callables, and finally defaults. Within the same priority, Morph uses callable compatibility rank to choose the best candidate.

Specifying callables in the defaults section will make the callables available to any mapper at the lowest priority.

Property callables can also receive ordered context source arguments. Context arguments must be exact source fields or zero-argument methods; Morph does not infer or strip accessor prefixes for them.

packages:
- source: example.com/foodplanner/foodpb
  target: example.com/foodplanner/food
  types:
  - name: Recipe
    struct:
      properties:
      - name: Title
        callable:
          forward:
            ref: example.com/foodplanner/food.OmittableFromPresence
            args:
            - source: MorphHasTitle
Presets

Presets

Morph allows you to write named collections of default configuration which can be applied at the package or type level. If you have a common pattern you want to use for certain packages, then it means you can drastically cut down on duplicate config. Presets can be defined as so:

presets:
  protobuf:
    bidirectional: true
    callables:
    - google.golang.org/protobuf/types/known/timestamppb.Timestamp.AsTime
    - google.golang.org/protobuf/types/known/timestamppb.New

    enum:
      failureMode: error
      patterns:
        source: "{{ .Type.Pascal }}_{{ .Type.Screaming }}_{{ .Value.Screaming }}"
        target: "{{ .Type.Pascal }}{{ .Value.Pascal }}"

    mappers:
      forward:
        name: Map{{ .Target.Type }}FromProto
        signature:
          accepts: pointer
          returns: value
      inverse:
        name: Map{{ .Target.Type }}ToProto
        signature:
          accepts: value
          returns: pointer

    optionality:
      onNilSourcePointer: zero
      onZeroSourceValue: nil

# And then applied:
packages:
- source: example.com/foopb
  target: example.com/foo
  preset: protobuf
  types:
  - name: Bar
    # Or at the specific type level
    preset: protobuf

Other Common Scenarios

Configuring Output

Configuring Output

By default, Morph generates code into a single mapping package, in a mapping directory next to the configuration file. You can configure this globally:

defaults:
  packages:
    output:
      strategy: single_package
      path: internal/mapping
      package: mapping
      filename: mapping.morph.go

Or, for a specific package mapping:

packages:
- source: example.com/foodplanner/foodpb
  target: example.com/foodplanner/food
  output:
    strategy: target_package
    filename: mapping.morph.go
  types:
  - name: Recipe

There are 3 output strategies:

  • single_package writes generated code to the configured path and package.
  • source_package writes generated code into the source package.
  • target_package writes generated code into the target package.

For source_package and target_package, only filename is used. The package name and path come from the existing package Morph is writing into.

Customizing Mapper Function Names

Customizing Mapper Function Names

Morph generates mapper function names from templates. You can configure mapper names in defaults, presets, on packages, or on individual types. If you're generating ProtoBuf mappings, for example, you might want names which make the direction clearer:

packages:
- source: example.com/foodplanner/foodpb
  target: example.com/foodplanner/food
  mappers:
    forward:
      name: Map{{ .Target.Type }}FromProto
    inverse:
      name: Map{{ .Target.Type }}ToProto
  bidirectional: true
  types:
  - name: Recipe

With the above config, Morph would generate names like MapRecipeFromProto and MapRecipeToProto.

Patterns use Go's text/template library. Input to the template is nameTemplateData found in naming.go. The package values are the Go package names with the first letter uppercased, and the signature values are rendered as Value or Pointer.

Customizing Mapper Signatures

Customizing Mapper Signatures

Similar to configuring mapper names, you can customize the signature of a mapper, controlling whether the function accepts/returns pointers/values:

packages:
- source: example.com/foodplanner/foodpb
  target: example.com/foodplanner/food
  types:
  - name: Recipe
    mappers:
      forward:
        name: Map{{ .Source.Type }}PointerTo{{ .Target.Type }}
        signature:
          accepts: pointer
          returns: value
Overriding Property / Enum Value Mapping

Overriding Property / Enum Value Mapping

Morph will try to match logical struct properties by name, including case-insensitive matches. A property is usually backed by a Go field, but can also be backed by getter and setter methods. If property names don't match clearly, you can map them explicitly:

packages:
- source: example.com/foodplanner/foodpb
  target: example.com/foodplanner/food
  types:
  - name: Recipe
    struct:
      properties:
      - source: RecipeId
        target: ID

You can also configure exact accessors when Morph should read or write through specific methods:

packages:
- source: example.com/foodplanner/foodpb
  target: example.com/foodplanner/food
  types:
  - name: Recipe
    struct:
      properties:
      - source: EmailAddress
        target: Email
        accessors:
          forward:
            read: GetEmailAddress
            write: SetEmail

Enums work similarly. Morph will try to infer enum mappings by normalizing names, but you can provide explicit value mappings where names don't line up:

packages:
- source: example.com/foodplanner/foodpb
  target: example.com/foodplanner/food
  types:
  - source: Difficulty
    target: RecipeDifficulty
    enum:
      failureMode: error
      values:
        Difficulty_DIFFICULTY_UNSPECIFIED: RecipeDifficultyUnknown

By default, enum mappers return an error when the source value falls through the generated switch. Set failureMode: zero to return the target enum's zero value instead, or failureMode: fallback to return a configured target enum constant. Fallback mode also allows inferred source constants with no target match; those constants are omitted from the switch and use the fallback at runtime. Explicit values entries are still validated.

packages:
- source: example.com/foodplanner/foodpb
  target: example.com/foodplanner/food
  types:
  - source: Difficulty
    target: RecipeDifficulty
    enum:
      failureMode: fallback
      fallback:
        forward: RecipeDifficultyUnknown
        inverse: Difficulty_UNSPECIFIED

For enums with regular generated naming patterns, you can also configure patterns instead of listing every value:

packages:
- source: example.com/foodplanner/foodpb
  target: example.com/foodplanner/food
  types:
  - source: Difficulty
    target: RecipeDifficulty
    enum:
      patterns:
        source: "{{ .Type.Pascal }}_{{ .Type.Screaming }}_{{ .Value.Screaming }}"
        target: "{{ .Type.Pascal }}{{ .Value.Pascal }}"

Patterns use Go's text/template library. Input to the template is enumTemplateData found in planner_enum.go.

Omitting Properties

Omitting Properties

Morph reports coverage warnings when a target property cannot be populated from a source property, or vice versa. If a property is intentionally outside the mapping, you can omit it like so:

packages:
- source: example.com/foodplanner/foodpb
  target: example.com/foodplanner/food
  bidirectional: true
  types:
  - name: Recipe
    struct:
      omit:
        both:
        - Name
        source:
        - InternalState
        target:
        - CreatedAt
        - UpdatedAt

Use both when the same logical property exists on both sides but should not be mapped. Use source for source-only properties that should be ignored, and target for target-only properties that should be left unset.

For bidirectional mappings, source and target omissions are inverted automatically. Properties listed under source are treated as target omissions on the inverse mapper, and properties listed under target are treated as source omissions on the inverse mapper. Properties listed under both remain matched omissions in both directions.

Bidirectional Mapping

Bidirectional Mapping

Many mappings are useful in both directions. You can enable bidirectional mapping in defaults, in a preset, on a package, or on a specific type:

packages:
- source: example.com/foodplanner/foodpb
  target: example.com/foodplanner/food
  bidirectional: true
  types:
  - name: Recipe
  - source: Difficulty
    target: RecipeDifficulty

This will generate both foodpb -> food and food -> foodpb mappings. Struct property mappings and enum value mappings are inverted automatically for the inverse mapper. Enum fallback values are configured directionally because each generated mapper returns a different target enum type.

If only one type should be bidirectional, configure it at the type level:

packages:
- source: example.com/foodplanner/foodpb
  target: example.com/foodplanner/food
  types:
  - name: Recipe
    bidirectional: true

Known Limitations

  • Morph only generates top-level mappers for struct-to-struct or enum-to-enum mappings and does not support generating mapping functions for other types (e.g. basic types, slices, maps, so on).
  • Morph does not load test packages, so cannot create mappings for types in test files.
  • Morph does not support embedded fields.
  • Morph only recognizes the standard, built-in error type for discovery, not custom aliases or wrappers.
  • Morph does not support creating mappers explicitly for generic types. See docs/decisions/01-high-order-explicit-roots.md for the rationale.
  • Morph assumes at least one package referenced in the spec is the main module. If this is not the case, Morph will not be able to figure out the workspace and planning will fail.
  • If using single_package output, package name detection includes files that have build constraints, which could mean either the package name is incorrect, or that an error is returned when it shouldn't be.

Future Enhancements

  • Assignment of literal / constant values for unmapped fields (i.e. while mapping set field x to y)
  • CLI improvements:
    • morph plan / morph explain, some sort of human-readable and/or machine-readable plan view
    • morph init, maybe point it at packages or something? Or maybe a different command which creates or updates config to include pairs of types found? morph scan or something?
  • Package-local helpers could support cross-package mappings involving unexported fields.
  • Built-in helpers which can be used for discovery en masse

License

MIT

About

A Go tool for automatically generating mapping code for similar types.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages