mix encryptor.ecto.gen.plan (Encryptor.Ecto v0.2.0)

Copy Markdown View Source

Generates a Encryptor.Ecto.Migration plan skeleton from the host's schemas.

mix encryptor.ecto.gen.plan [--app APP] [--repo REPO]
                            [--module MODULE] [--output PATH]

The generator loads the host's application, reads every Ecto schema in it, and emits one rewrite block per schema that has at least one field whose type is a module rather than a built-in Ecto type, with one field line per such field.

The file it writes does not compile, and that is the feature

ADR-0004 decision 7 fixes three behaviours that each read as a defect once, and this task implements them deliberately. All three come from the same place: a plan decides what happens to production rows, so the facts a generator cannot know are left visibly unanswered rather than guessed.

What it doesWhy
TenantEmits tenant_from :TODO_tenant_columnWhich column identifies the tenant is a fact about the host's domain model that no generator can read off a schema, and guessing it re-encrypts every row under one tenant's key. tenant_from is checked against the schema at mix compile (ADR-0002 decision 2), so the wrong answer cannot reach a row. A skeleton that compiled would be a skeleton somebody ran
Target typeEmits to: as a comment, never a valueThe generator cannot know which type module a field is being rewritten into, and a half-guess produces a diff that looks reviewed
Field listOver-reportsPer ADR-0004 decision 1 this package tests for no other library's marker function - not cloak_ecto's __cloak__/0, not any other - so a custom Ecto.Type of the host's that encrypts nothing is listed too. A false positive a human deletes is strictly better than a field nobody noticed

The generated file carries a comment saying all of this, because the person who runs mix compile next may not be the person who ran this task.

What counts as a candidate

A field is a candidate when its declared type is a module rather than one of Ecto's own types: a plain Ecto.Type module, or an Ecto.ParameterizedType (which is what use Encryptor.Ecto.Binary and cloak_ecto's type modules both produce). Ecto's own type modules - Ecto.Enum, Ecto.UUID - are not candidates, which is a fact about which application a module belongs to rather than an inspection of its interface.

Two limits are worth stating rather than discovering:

  • Composite types are not candidates. A field typed {:array, MyType} is skipped, because from: names a module that reads one field's bytes and the array's element type is not that module. Check such fields by hand.
  • Embedded schemas are skipped. A rewrite names a table, and an embedded schema has none.

This package's own types are candidates like any other, which is correct rather than incidental: a field moving between tenant strategies is a full rewrite with from: and to: naming the same module (ADR-0002 decision 3).

Flags

FlagDefault
--app APPthe current Mix project's applicationWhich application's modules to read schemas from
--repo REPOthe app's :ecto_repos, when it names exactly oneThe repo the plan names. Unlike the tenant column this is a fact the host has already declared, so a single configured repo is read rather than guessed; anything else is a usage error
--module MODULE<App>.Encryption.MigrationThe plan module to generate
--output PATHderived from --module under lib/Where to write the file

Exit codes

0The file was written; its path is printed
2Usage error; the host's application would not compile; no candidate field was found; the repo could not be resolved; or the output file already exists - the generator never overwrites a plan

No release equivalent, and that is acknowledged

Every other verb in the family wraps a function on Encryptor.Ecto.Migrator, so a release can do everything a laptop can (ADR-0004 decision 6). This one cannot: reading __schema__(:type, field) needs the host's schema modules compiled and loaded, which a Mix task in the host's project has and a release command does not. ADR-0004 Q6 records that as stated rather than decided. It is the right trade here because this verb writes source into a working tree, which is not a thing a release does.

Summary

Functions

The schemas of modules that have at least one candidate field.

The plan module --module defaults to: <App>.Encryption.Migration.

Where --output defaults to for plan_module: its conventional path under lib/.

The source of the plan skeleton, as it lands in the host's tree.

Functions

candidates(modules)

@spec candidates([module()]) :: [{module(), [{atom(), module()}]}]

The schemas of modules that have at least one candidate field.

Ordered by module name so that re-running the generator against an unchanged application produces an unchanged file.

default_module()

@spec default_module() :: module()

The plan module --module defaults to: <App>.Encryption.Migration.

default_output(plan_module)

@spec default_output(module()) :: String.t()

Where --output defaults to for plan_module: its conventional path under lib/.

source(plan_module, repo, found)

@spec source(module(), module(), [{module(), [{atom(), module()}]}]) :: String.t()

The source of the plan skeleton, as it lands in the host's tree.