Repository: elixir-ecto/ecto
Pull request #4599 (2025-04-02 17:38:16)

Add @schema_redact: :all_except_primary_keys module attribute to Ecto Schema
Initial proposal: https://groups.google.com/g/elixir-ecto/c/kQJN-7HPn6E

@josevalim initially approved of the second of my two proposed options:
```
@schema_redact :all_except_primary_keys
schema "users" do
  # ...
end
```

While implementing, I realized that it's probably not best to redact _all_ fields. I opted to keep the primary keys of a schema even if `@schema_redact` is set to something like `:all`, hence the rename to `:all_except_primary_keys`.

TODO:
- [X] Document new property once implementation/naming is settled

Future ideas:
- Actually add `@schema_redact: :all`, but would that be useful?
- Add separate attributes only deriving inspect for a list of specific fields?

solnic: Fantastic! PKs by default sounds very reasonable. Thank you for working on this 💜
josevalim: Hi @Nezteb, in this case, I would call it `:all_except_primary_keys`.

Btw, implementation wise, I'd implement it here:

https://github.com/elixir-ecto/ecto/blob/master/lib/ecto/schema.ex#L1995-L1997

So it does something like this:

    Keyword.get(opts, :redact, fn -> compute_default_redact_from_module_attribute(...) end)

This way, you don't need to change changesets nor nothing else, we reuse the existing redact infrastructure. WDYT?

Patch:
diff --git a/lib/ecto/schema.ex b/lib/ecto/schema.ex
index c8768f14..6fd48378 100644
--- a/lib/ecto/schema.ex
+++ b/lib/ecto/schema.ex
@@ -130,6 +130,9 @@ defmodule Ecto.Schema do
   inspect on the schema unless the schema module is tagged with
   the option `@derive_inspect_for_redacted_fields false`.
 
+  A schema module tagged with `@schema_redact :all_except_primary_keys` will
+  redact all fields except primary keys.
+
   ## Schema attributes
 
   Supported attributes for configuring the defined schema. They must
@@ -155,6 +158,11 @@ defmodule Ecto.Schema do
       which generates structs and queries without context. Context are not used
       by the built-in SQL adapters.
 
+    * `@schema_redact` - If set to `:all_except_primary_keys`, Ecto will
+      treat all non-primary key fields as if they were individually marked
+      as redacted. Defaults to `false`, as no fields are redacted by default.
+      The value set here can be changed per field through the `:redact` option.
+
     * `@foreign_key_type` - configures the default foreign key type
       used by `belongs_to` associations. It must be set in the same
       module that defines the `belongs_to`. Defaults to `:id`;
@@ -1992,7 +2000,14 @@ defmodule Ecto.Schema do
     writable = opts[:writable] || :always
     put_struct_field(mod, name, Keyword.get(opts, :default))
 
-    if Keyword.get(opts, :redact, false) do
+    redact_field? = Keyword.get_lazy(opts, :redact, fn ->
+      case Module.get_attribute(mod, :schema_redact, false) do
+        :all_except_primary_keys -> not pk?
+        false -> false
+      end
+    end)
+
+    if redact_field? do
       Module.put_attribute(mod, :ecto_redact_fields, name)
     end
 


Test patch:
diff --git a/test/ecto/changeset_test.exs b/test/ecto/changeset_test.exs
index ede16492..b70216f0 100644
--- a/test/ecto/changeset_test.exs
+++ b/test/ecto/changeset_test.exs
@@ -3607,6 +3607,17 @@ defmodule Ecto.ChangesetTest do
     end
   end
 
+  defmodule RedactAllExceptPrimaryKeysSchema do
+    use Ecto.Schema
+
+    @schema_redact :all_except_primary_keys
+    schema "redacted_schema" do
+      field :password, :string
+      field :username, :string
+      field :virtual_pass, :string, virtual: true
+    end
+  end
+
   defmodule RedactedEmbeddedSchema do
     use Ecto.Schema
 
@@ -3683,5 +3694,12 @@ defmodule Ecto.ChangesetTest do
       assert inspect(changeset) =~ "hunter2"
       refute inspect(changeset) =~ "**redacted**"
     end
+
+    test "redacts all non-primary-key fields when schema sets @schema_redact :all_except_primary_keys" do
+      changeset = Ecto.Changeset.cast(%RedactAllExceptPrimaryKeysSchema{}, %{username: "Hunter", password: "hunter2"}, [:username, :password])
+      assert inspect(changeset) =~ "id"
+      refute inspect(changeset) =~ "hunter2"
+      assert inspect(changeset) =~ "**redacted**"
+    end
   end
 end
diff --git a/test/ecto/schema_test.exs b/test/ecto/schema_test.exs
index 15e9c27b..d84d78a7 100644
--- a/test/ecto/schema_test.exs
+++ b/test/ecto/schema_test.exs
@@ -192,6 +192,37 @@ defmodule Ecto.SchemaTest do
     refute inspect(%Schema{temp: "hunter2"}) =~ "hunter2"
   end
 
+  defmodule SchemaWithRedactAllExceptPrimaryKeys do
+    use Ecto.Schema
+
+    @schema_redact :all_except_primary_keys
+    schema "my_schema" do
+      field :password, :string
+      field :temp, :any, default: "temp", virtual: true
+    end
+  end
+
+  test "schema with @schema_redact: :all_except_primary_keys derives inspect for all non-primary-key fields" do
+    inspected_schema = inspect(%SchemaWithRedactAllExceptPrimaryKeys{password: "hunter2"})
+
+    assert inspected_schema =~ "id"
+    refute inspected_schema =~ "hunter2"
+    refute inspected_schema =~ "temp"
+  end
+
+  defmodule SchemaWithRedactFalse do
+    use Ecto.Schema
+
+    @schema_redact false
+    schema "my_schema" do
+      field :name, :string
+    end
+  end
+
+  test "schema with @schema_redact: false doesn't derive inspect" do
+    assert inspect(%SchemaWithRedactFalse{name: "Hunter"}) =~ "Hunter"
+  end
+
   defmodule SchemaWithoutDeriveInspect do
     use Ecto.Schema