Claude Skill

ecto-patterns

'Use when a task touches Ecto, even a how-to question: schemas, changesets,

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download oliver-kriska-claude-elixir-phoenix-targets_amp_skills_ecto-patterns-9767a82.zip · 11 KB
Part of oliver-kriska/claude-elixir-phoenix — 93 skills

Install

skills CLI npx skills add https://github.com/oliver-kriska/claude-elixir-phoenix/tree/main/targets/amp/skills/ecto-patterns
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install oliver-kriska-claude-elixir-phoenix@llmmart
Git git clone https://github.com/oliver-kriska/claude-elixir-phoenix.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole oliver-kriska/claude-elixir-phoenix collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Ecto Patterns Reference

Reference for working with Ecto schemas, queries, and migrations.

Iron Laws — Never Violate These

  1. CHANGESETS ARE FOR EXTERNAL DATA — Use cast/4 for user/API input, change/2 or put_change/3 for internal trusted data
  2. NEVER USE :float FOR MONEY — Always use :decimal or :integer (cents)
  3. NO RAILS-STYLE POLYMORPHIC ASSOCIATIONS — They break foreign key constraints; use multiple nullable FKs or separate join tables
  4. ALWAYS PIN VALUES IN QUERIES — u.name == ^user_input is safe, string interpolation causes SQL injection
  5. PRELOAD COLLECTIONS, NOT INDIVIDUALS — Preloading in loops = N+1 queries
  6. CONSTRAINTS BEAT VALIDATIONS FOR RACE CONDITIONS — Validations provide quick feedback, constraints provide DB-level safety
  7. SEPARATE QUERIES FOR has_many, JOIN FOR belongs_to — Avoids row multiplication
  8. NO IMPLICIT CROSS JOINS — from(a in A, b in B) without on: creates Cartesian product
  9. DEDUP BEFORE cast_assoc WITH SHARED DATA — When multiple parents share child data, deduplicate child records BEFORE building changesets. Dedup only works within a single changeset

Quick Schema Template

defmodule MyApp.Context.Entity do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id

  schema "entities" do
    field :name, :string
    field :status, Ecto.Enum, values: [:draft, :active, :archived]
    field :amount_cents, :integer  # Never :float for money!
    belongs_to :user, MyApp.Accounts.User
    timestamps(type: :utc_datetime_usec)
  end

  def changeset(entity, attrs) do
    entity
    |> cast(attrs, [:name, :status, :amount_cents])
    |> validate_required([:name])
    |> foreign_key_constraint(:user_id)
  end
end

Quick Decisions

cast vs put_change vs change

Function Use When
cast/4 External data (user input, API)
put_change/3 Internal trusted data (timestamps, computed)
change/2 Internal data from existing struct

Preload Strategy

Relationship Strategy
belongs_to JOIN (single query)
has_many Separate queries (avoid row multiplication)

Common Anti-patterns

Wrong Right
field :amount, :float field :amount_cents, :integer
"SELECT * WHERE name = '#{name}'" from(u in User, where: u.name == ^name)
Repo.all(User) \|> Enum.filter(& &1.active) from(u in User, where: u.active)
Preloading in loops Repo.preload(posts, :comments)
Repo.get!(User, user_id) with user input Repo.get(User, id) + handle nil
{:ok, _} = Repo.update(cs) inside Repo.transaction case/with + Repo.rollback(cs) (see transactions.md)

References

For detailed patterns, see:

  • references/changesets.md - cast vs put_change, custom validations, prepare_changes
  • references/queries.md - Composable queries, dynamic, subqueries, preloading
  • references/migrations.md - Safe migrations, concurrent indexes, NOT NULL
  • references/transactions.md - Repo.transact, Ecto.Multi, upserts
Files (claude-elixir-phoenix)
  • references
    • changesets.md 4.2 KB
      # Changesets Reference
      
      ## cast vs put_change vs force_change
      
      | Function | Use When |
      |----------|----------|
      | `cast/4` | External data (user input, API) |
      | `put_change/3` | Internal trusted data (timestamps, computed) |
      | `change/2` | Internal data from existing struct |
      | `force_change/3` | When you need to set even if value unchanged |
      
      ```elixir
      # External data - use cast
      def registration_changeset(user, attrs) do
        user
        |> cast(attrs, [:email, :password, :name])
        |> validate_required([:email, :password, :name])
        |> validate_email()
        |> hash_password()
      end
      
      # Internal data - use put_change
      defp hash_password(changeset) do
        case changeset do
          %{valid?: true, changes: %{password: password}} ->
            put_change(changeset, :hashed_password, Bcrypt.hash_pwd_salt(password))
          _ ->
            changeset
        end
      end
      ```
      
      ## Multiple Changesets per Schema
      
      ```elixir
      # Different changesets for different operations
      def registration_changeset(user, attrs) do
        user
        |> cast(attrs, [:email, :password, :name])
        |> validate_required([:email, :password, :name])
        |> validate_email()
        |> validate_length(:password, min: 12, max: 72)
        |> hash_password()
      end
      
      def profile_changeset(user, attrs) do
        user
        |> cast(attrs, [:name, :bio, :avatar])
        |> validate_length(:bio, max: 500)
      end
      
      def password_changeset(user, attrs) do
        user
        |> cast(attrs, [:password])
        |> validate_required([:password])
        |> validate_length(:password, min: 12, max: 72)
        |> hash_password()
      end
      
      def admin_changeset(user, attrs) do
        user
        |> cast(attrs, [:role, :permissions])
        |> validate_inclusion(:role, [:user, :moderator, :admin])
      end
      ```
      
      ## Custom Validations
      
      ```elixir
      def changeset(order, attrs) do
        order
        |> cast(attrs, [:quantity, :unit_price])
        |> validate_required([:quantity, :unit_price])
        |> validate_positive_total()
      end
      
      defp validate_positive_total(changeset) do
        validate_change(changeset, :quantity, fn :quantity, quantity ->
          unit_price = get_field(changeset, :unit_price) || 0
      
          if quantity * unit_price < 0 do
            [quantity: "total must be positive"]
          else
            []
          end
        end)
      end
      ```
      
      ## prepare_changes for Transaction-Safe Operations
      
      ```elixir
      def changeset(post, attrs) do
        post
        |> cast(attrs, [:title, :body, :published_at])
        |> prepare_changes(fn changeset ->
          # Runs inside the transaction
          if get_change(changeset, :published_at) do
            changeset.repo.update_all(
              from(p in Post, where: p.author_id == ^post.author_id),
              inc: [post_count: 1]
            )
          end
          changeset
        end)
      end
      ```
      
      ## Embedded Schemas
      
      **Use embedded_schema when:**
      
      - Never query child independently
      - Never share child across parents
      - Always loaded with parent (single query)
      
      ```elixir
      # Embedded schema (stored as JSONB)
      defmodule MyApp.Accounts.Profile do
        use Ecto.Schema
        import Ecto.Changeset
      
        @primary_key false
        embedded_schema do
          field :dark_mode, :boolean, default: false
          field :timezone, :string
          field :language, :string, default: "en"
        end
      
        def changeset(profile, attrs) do
          profile
          |> cast(attrs, [:dark_mode, :timezone, :language])
        end
      end
      
      # Parent schema
      defmodule MyApp.Accounts.User do
        schema "users" do
          field :email, :string
          embeds_one :profile, Profile, on_replace: :update
          embeds_many :addresses, Address, on_replace: :delete
        end
      
        def changeset(user, attrs) do
          user
          |> cast(attrs, [:email])
          |> cast_embed(:profile)
          |> cast_embed(:addresses)
        end
      end
      ```
      
      Migration:
      
      ```elixir
      add :profile, :map
      add :addresses, :map, default: "[]"
      ```
      
      ## Field Types
      
      | Need | Ecto Type | PostgreSQL | Notes |
      |------|-----------|------------|-------|
      | Primary key | `:binary_id` | `uuid` | Prefer UUIDs |
      | Text | `:string` | `varchar` | Default |
      | Long text | `:text` | `text` | No limit |
      | Integer | `:integer` | `integer` | |
      | Money | `:integer` | `integer` | Store cents (never float!) |
      | Decimal | `:decimal` | `numeric` | Precise calculations |
      | Boolean | `:boolean` | `boolean` | |
      | Date | `:date` | `date` | |
      | DateTime | `:utc_datetime_usec` | `timestamptz` | With timezone + microseconds |
      | JSON | `:map` | `jsonb` | Use for dynamic |
      | Enum | `Ecto.Enum` | `varchar` | Type-safe |
      | Array | `{:array, :string}` | `varchar[]` | |
      
    • fulltext-search.md 5.6 KB
      # PostgreSQL Full-Text Search with Ecto
      
      Native full-text search without external dependencies. Based on
      [Search is Not Magic with PostgreSQL](https://www.codecon.sk/search-is-not-magic-with-postgresql).
      
      ## Strategy Decision Tree
      
      | Need | Strategy | Extension |
      |------|----------|-----------|
      | Exact/weighted text search | Full-text search (tsvector) | Built-in |
      | Typo tolerance / fuzzy | Trigram similarity (pg_trgm) | `pg_trgm` |
      | Semantic / AI search | Vector search (pgvector) | `pgvector` |
      | All of the above | Hybrid with RRF | Multiple |
      
      ## 1. Full-Text Search (tsvector/tsquery)
      
      ### Migration — Generated Column (Preferred, PostgreSQL 12+)
      
      ```elixir
      def up do
        execute """
        ALTER TABLE articles
          ADD COLUMN searchable tsvector
          GENERATED ALWAYS AS (
            setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
            setweight(to_tsvector('english', coalesce(body, '')), 'B')
          ) STORED;
        """
      
        create index(:articles, [:searchable], using: :gin)
      end
      
      def down do
        alter table(:articles) do
          remove :searchable
        end
      end
      ```
      
      Generated columns auto-update on INSERT/UPDATE — no triggers needed.
      
      **When to use triggers instead**: When the tsvector depends on associated
      records (e.g., tags from a join table). Generated columns can only reference
      columns in the same row.
      
      ### Basic Search Query
      
      ```elixir
      def search_articles(query_string) do
        from(a in Article,
          where: fragment(
            "searchable @@ websearch_to_tsquery('english', ?)",
            ^query_string
          ),
          order_by: [desc: fragment(
            "ts_rank_cd(searchable, websearch_to_tsquery('english', ?), 32)",
            ^query_string
          )]
        )
        |> Repo.all()
      end
      ```
      
      ### Search with Highlights and Pagination
      
      ```elixir
      def search_articles(query_string, opts \\ []) do
        page = Keyword.get(opts, :page, 1)
        per_page = Keyword.get(opts, :per_page, 20)
      
        from(a in Article,
          where: fragment("searchable @@ websearch_to_tsquery('english', ?)", ^query_string),
          select: %{
            id: a.id,
            title: a.title,
            headline: fragment(
              "ts_headline('english', ?, websearch_to_tsquery('english', ?), 'StartSel=<mark>, StopSel=</mark>')",
              a.body, ^query_string
            ),
            rank: fragment("ts_rank_cd(searchable, websearch_to_tsquery('english', ?), 32)", ^query_string)
          },
          order_by: [desc: fragment("ts_rank_cd(searchable, websearch_to_tsquery('english', ?), 32)", ^query_string)],
          offset: ^((page - 1) * per_page),
          limit: ^per_page
        )
        |> Repo.all()
      end
      ```
      
      ### Multi-Language Support
      
      ```elixir
      # Dynamic language via regconfig casting
      from(a in Article,
        where: fragment(
          "to_tsvector(?::text::regconfig, ?) @@ to_tsquery(?::text::regconfig, ?)",
          ^language, a.body, ^language, ^query
        )
      )
      ```
      
      ### websearch_to_tsquery Syntax (Google-style)
      
      | Input | Matches |
      |-------|---------|
      | `elixir phoenix` | Both words |
      | `"exact phrase"` | Exact phrase |
      | `elixir OR phoenix` | Either word |
      | `-deprecated` | Excludes word |
      
      ### Weight Meanings
      
      | Weight | Use | Boost |
      |--------|-----|-------|
      | A | Title | Highest |
      | B | Subtitles | High |
      | C | Body | Medium |
      | D | Metadata | Lower |
      
      ## 2. Trigram Similarity (pg_trgm) — Fuzzy/Typo Tolerance
      
      ```elixir
      # Migration
      execute "CREATE EXTENSION IF NOT EXISTS pg_trgm"
      execute "CREATE INDEX products_name_trgm_idx ON products USING gin(name gin_trgm_ops)"
      
      # Query
      from(p in Product,
        where: fragment("similarity(?, ?) > ?", p.name, ^term, 0.3),
        order_by: [desc: fragment("similarity(?, ?)", p.name, ^term)]
      )
      ```
      
      Trigrams compare 3-character groups — handles typos, misspellings, partial matches.
      Threshold 0.3 is a good default; tune based on your data.
      
      ## 3. Hybrid Search with RRF (Reciprocal Rank Fusion)
      
      Combine multiple search strategies by normalizing ranks:
      
      ```elixir
      # Each strategy returns %{id, rank} with: 1 / (60 + distance)
      similarity_results = search_by_trigram(term)
      fulltext_results = search_by_tsvector(term)
      
      # Merge with deduplication
      (similarity_results ++ fulltext_results)
      |> Enum.group_by(& &1.id)
      |> Enum.map(fn {id, ranks} -> {id, Enum.sum(Enum.map(ranks, & &1.rank))} end)
      |> Enum.sort_by(&elem(&1, 1), :desc)
      ```
      
      ### Multi-Word Query Normalization
      
      ```elixir
      defp normalize_query(text) do
        text
        |> String.trim()
        |> String.downcase()
        |> String.replace(~r/\s+/, " ")
        |> String.split(" ")
        |> Enum.join(" & ")
      end
      ```
      
      ## Performance
      
      ```elixir
      # ALWAYS use GIN index for tsvector
      create index(:articles, [:searchable], using: :gin)
      
      # Partial index for large tables
      create index(:articles, [:searchable], using: :gin, where: "published_at IS NOT NULL")
      
      # GIN indexes only used with LIMIT — always paginate
      ```
      
      ## Anti-patterns
      
      ```elixir
      # WRONG: Computing tsvector at query time (slow, no index!)
      from(a in Article,
        where: fragment("to_tsvector('english', title || ' ' || body) @@ to_tsquery(?)", ^q)
      )
      
      # WRONG: Using LIKE for search (no ranking, no stemming)
      from(a in Article, where: ilike(a.title, ^"%#{query}%"))
      
      # WRONG: Using triggers when generated columns suffice
      # Generated columns are simpler and auto-maintained
      
      # WRONG: Assuming PG can't do fuzzy search
      # pg_trgm handles typo tolerance natively — no need for Elasticsearch just for fuzzy
      ```
      
      ## When to Use External Search
      
      PostgreSQL handles most use cases (100K-10M docs). Consider Meilisearch/Elasticsearch for:
      
      - Faceted search with complex filters across many dimensions
      - Multi-language with mixed alphabets in same field
      - Real-time indexing of 10M+ documents
      - Advanced search analytics
      
      **Further reading**: [Search is Not Magic with PostgreSQL](https://www.codecon.sk/search-is-not-magic-with-postgresql)
      — covers trigrams, full-text, vector search, and hybrid patterns with Ecto examples.
      
    • migrations.md 4.5 KB
      # Migrations Reference
      
      ## Basic Migration
      
      ```elixir
      defmodule MyApp.Repo.Migrations.CreateUsers do
        use Ecto.Migration
      
        def change do
          create table(:users, primary_key: false) do
            add :id, :binary_id, primary_key: true
            add :email, :string, null: false
            add :name, :string
            add :role, :string, default: "user"
      
            timestamps(type: :utc_datetime_usec)
          end
      
          create unique_index(:users, [:email])
        end
      end
      ```
      
      ## Add Foreign Key (Safe - 2 steps)
      
      ```elixir
      # Step 1: Add without validation (fast, no table lock)
      def change do
        alter table(:posts) do
          add :user_id, references(:users, type: :binary_id, validate: false), null: false
        end
      
        create index(:posts, [:user_id])
      end
      
      # Step 2: Validate in separate migration (separate deploy)
      def change do
        execute "ALTER TABLE posts VALIDATE CONSTRAINT posts_user_id_fkey", ""
      end
      ```
      
      ## Add NOT NULL (Safe - 3 steps)
      
      ```elixir
      # Step 1: Add check constraint without validation
      def change do
        create constraint("products", :active_not_null,
          check: "active IS NOT NULL",
          validate: false
        )
      end
      
      # Step 2: Backfill data (separate deploy)
      def change do
        execute "UPDATE products SET active = false WHERE active IS NULL", ""
      end
      
      # Step 3: Validate constraint, add NOT NULL, drop constraint
      def change do
        execute "ALTER TABLE products VALIDATE CONSTRAINT active_not_null", ""
        execute "ALTER TABLE products ALTER COLUMN active SET NOT NULL", ""
        drop constraint("products", :active_not_null)
      end
      ```
      
      ## Pre-Migration Safety: Check Data BEFORE Unique Index
      
      A unique index on a table with existing duplicates fails at deploy time —
      in the worst case mid-release. Check FIRST, and include soft-deleted rows:
      they're invisible in the app but still block the index.
      
      ```sql
      -- Find duplicates the index would reject (run before writing the migration)
      SELECT email, COUNT(*) FROM users
      GROUP BY email HAVING COUNT(*) > 1;
      
      -- Soft-deleted rows count too — check whether they collide
      SELECT email, COUNT(*) FILTER (WHERE deleted_at IS NOT NULL) AS deleted,
             COUNT(*) FILTER (WHERE deleted_at IS NULL) AS live
      FROM users GROUP BY email HAVING COUNT(*) > 1;
      ```
      
      Resolutions, in order of preference:
      
      1. **Partial index** when soft-deleted rows may legitimately collide:
         `create unique_index(:users, [:email], where: "deleted_at IS NULL")`
      2. **Data-fix migration first** (separate deploy), then the index
      3. **Composite key** if the duplicates are actually valid scoping
         (e.g., unique per tenant: `unique_index(:users, [:org_id, :email])`)
      
      Same discipline applies before adding NOT NULL (check for NULLs) and
      before foreign keys (check for orphaned rows).
      
      ## Concurrent Index (Large Tables)
      
      ```elixir
      @disable_ddl_transaction true
      @disable_migration_lock true
      
      def change do
        create index(:posts, [:slug], concurrently: true)
      end
      ```
      
      ## Batched Data Migration
      
      ```elixir
      def change do
        # For large tables, process in batches
        execute &migrate_data/0, &rollback_data/0
      end
      
      defp migrate_data do
        repo().transaction(fn ->
          from(u in "users", where: is_nil(u.status), select: u.id, limit: 1000)
          |> repo().all()
          |> Enum.each(&update_user_status/1)
        end)
      end
      ```
      
      ## Mixed Primary Key Types (bigint + binary_id)
      
      When integrating libraries that use UUID PKs (Sagents, Oban, etc.)
      while your project uses bigint:
      
      ```elixir
      # WRONG: Global @foreign_key_type affects ALL associations
      @foreign_key_type :binary_id
      
      # RIGHT: Explicit type ONLY on specific associations
      schema "interviews" do
        belongs_to :user, MyApp.Accounts.User        # bigint (default)
        belongs_to :conversation, Agents.Conversation, type: :binary_id
      end
      ```
      
      In migrations, match the referenced table's PK type:
      
      ```elixir
      alter table(:interviews) do
        add :user_id, references(:users, type: :bigint), null: false
        add :conversation_id, references(:conversations, type: :binary_id)
      end
      ```
      
      ## Associations
      
      ```elixir
      # One-to-many (always specify on_delete!)
      has_many :posts, Post, on_delete: :delete_all
      belongs_to :user, User
      
      # Many-to-many
      many_to_many :tags, Tag, join_through: "post_tags", on_replace: :delete
      
      # Has one through
      has_one :organization, through: [:user, :organization]
      
      # Self-referential
      belongs_to :parent, __MODULE__
      has_many :children, __MODULE__, foreign_key: :parent_id
      ```
      
      ## Optimistic Locking
      
      ```elixir
      schema "products" do
        field :name, :string
        field :lock_version, :integer, default: 1
      end
      
      def changeset(product, attrs) do
        product
        |> cast(attrs, [:name])
        |> optimistic_lock(:lock_version)
      end
      
      # Usage - raises Ecto.StaleEntryError if version changed
      Repo.update!(changeset)
      ```
      
    • queries.md 4.8 KB
      # Queries Reference
      
      ## Composable Query Functions
      
      ```elixir
      defmodule MyApp.Posts.PostQuery do
        import Ecto.Query
      
        def base, do: from(p in Post, as: :post)
      
        def published(query \\ base()) do
          from p in query, where: not is_nil(p.published_at)
        end
      
        def by_author(query \\ base(), author_id) do
          from p in query, where: p.author_id == ^author_id
        end
      
        def recent(query \\ base(), days \\ 7) do
          cutoff = Date.add(Date.utc_today(), -days)
          from p in query, where: p.inserted_at >= ^cutoff
        end
      
        def ordered(query \\ base(), direction \\ :desc) do
          from p in query, order_by: [{^direction, p.inserted_at}]
        end
      end
      
      # Usage: Pipeline composition
      PostQuery.base()
      |> PostQuery.published()
      |> PostQuery.by_author(author_id)
      |> PostQuery.ordered()
      |> Repo.all()
      ```
      
      ## Dynamic Queries
      
      ```elixir
      def filter_where(params) do
        Enum.reduce(params, dynamic(true), fn
          {"author", value}, dynamic ->
            dynamic([p], ^dynamic and p.author == ^value)
      
          {"category", value}, dynamic ->
            dynamic([p], ^dynamic and p.category == ^value)
      
          {"title_contains", value}, dynamic ->
            dynamic([p], ^dynamic and ilike(p.title, ^"%#{value}%"))
      
          {_, _}, dynamic ->
            dynamic  # Ignore unknown params
        end)
      end
      
      # Usage
      def list_posts(params) do
        Post
        |> where(^filter_where(params))
        |> Repo.all()
      end
      ```
      
      ## Subqueries
      
      ```elixir
      # Correlated subquery with parent_as
      comment_count = from c in Comment,
        where: parent_as(:post).id == c.post_id,
        select: count()
      
      from p in Post, as: :post,
        select: %{title: p.title, comment_count: subquery(comment_count)}
      ```
      
      ## Window Functions
      
      ```elixir
      from p in Post,
        select: %{
          id: p.id,
          title: p.title,
          row_num: row_number() |> over(partition_by: p.category_id, order_by: p.inserted_at),
          rank: rank() |> over(partition_by: p.category_id, order_by: [desc: p.view_count])
        }
      ```
      
      ## JSONB Queries (Ecto 3.12+)
      
      ### json_extract_path/2
      
      Extract values from JSONB columns without raw SQL:
      
      ```elixir
      # Extract nested JSON value
      from u in User,
        where: json_extract_path(u.settings, ["notifications", "email"]) == true,
        select: json_extract_path(u.metadata, ["theme"])
      
      # Compare with older fragment approach (still works but verbose)
      from u in User,
        where: fragment("?->>'email' = ?", u.settings["notifications"], "true")
      ```
      
      ### JSONB Pattern: Filtering on Nested Keys
      
      ```elixir
      # Dynamic key extraction
      def by_metadata_key(query, key, value) do
        from u in query,
          where: json_extract_path(u.metadata, ^[key]) == ^value
      end
      
      # Array access in JSONB
      from p in Product,
        where: json_extract_path(p.attributes, ["tags", 0]) == "featured"
      ```
      
      ### JSONB Anti-patterns
      
      ```elixir
      # WRONG: Loading all rows then filtering in Elixir
      Repo.all(User)
      |> Enum.filter(fn u -> u.settings["notifications"]["email"] == true end)
      
      # RIGHT: Filter in database
      from u in User,
        where: json_extract_path(u.settings, ["notifications", "email"]) == true
      
      # TIP: For frequently queried JSONB paths, create expression index
      # In migration:
      # execute "CREATE INDEX users_settings_notifications_idx ON users ((settings->'notifications'))"
      ```
      
      ## Repo.reload Options (Ecto 3.12+)
      
      ```elixir
      # Basic reload (unchanged)
      user = Repo.reload!(user)
      
      # With preloads (new in 3.12)
      user = Repo.reload!(user, preload: [:posts, :comments])
      
      # Force fresh query (skip query cache)
      user = Repo.reload!(user, force: true)
      ```
      
      ## Full-Text Search
      
      For PostgreSQL full-text search patterns, see
      `fulltext-search.md`.
      
      ## Preload Strategies
      
      ```elixir
      # Separate queries (default) - BEST for has_many
      # Two queries: posts + comments
      Repo.preload(post, :comments)
      
      # Join (single query) - BEST for belongs_to/has_one
      # One query with JOIN - watch for row multiplication with has_many!
      from(p in Post, preload: [:author])
      
      # Custom query for filtered/ordered preloads
      Repo.preload(post, comments: from(c in Comment, order_by: c.inserted_at, limit: 10))
      ```
      
      ## Pagination
      
      ```elixir
      def list_posts(params \\ %{}) do
        page = Map.get(params, :page, 1)
        per_page = Map.get(params, :per_page, 20)
      
        from(p in Post,
          order_by: [desc: p.inserted_at],
          offset: ^((page - 1) * per_page),
          limit: ^per_page
        )
        |> Repo.all()
      end
      ```
      
      ## Anti-patterns
      
      ```elixir
      # WRONG: N+1 queries
      users = Repo.all(User)
      Enum.map(users, fn u -> u.posts end)  # N queries!
      
      # RIGHT: Preload
      users = Repo.all(User) |> Repo.preload(:posts)
      
      # WRONG: Getting all then filtering in Elixir
      Repo.all(User) |> Enum.filter(& &1.active)
      
      # RIGHT: Filter in query
      from(u in User, where: u.active) |> Repo.all()
      
      # WRONG: String interpolation (SQL injection!)
      from(u in User, where: fragment("name = '#{name}'"))
      
      # RIGHT: Parameterized queries
      from(u in User, where: u.name == ^name)
      
      # WRONG: Using Repo.get! with user input (may raise)
      Repo.get!(User, user_provided_id)
      
      # RIGHT: Handle not found
      case Repo.get(User, id) do
        nil -> {:error, :not_found}
        user -> {:ok, user}
      end
      ```
      
    • transactions.md 4.4 KB
      # Transactions Reference
      
      ## Repo.transact (Simpler for most cases)
      
      ```elixir
      Repo.transact(fn ->
        with {:ok, user} <- create_user(params),
             {:ok, profile} <- create_profile(user) do
          {:ok, user}
        end
      end)
      ```
      
      ## Repo.transaction/1: changeset error handling
      
      Inside the callback form `Repo.transaction(fn -> ... end)`, a bare `{:ok, _} =`
      match on a write is a footgun. When the write returns `{:error, changeset}`, the
      match raises `MatchError`; the transaction rolls back and **re-raises** it, so the
      caller crashes (a `500` in a request) and the changeset's validation errors are
      lost. It does *not* return `{:error, %MatchError{}}` — nothing catches it for you.
      
      ```elixir
      # BAD — invalid changeset raises MatchError, which propagates and crashes the caller
      Repo.transaction(fn ->
        {:ok, updated} = Repo.update(changeset)
        updated
      end)
      
      # GOOD — explicit case + Repo.rollback/1 preserves the changeset
      Repo.transaction(fn ->
        case Repo.update(changeset) do
          {:ok, updated} -> updated
          {:error, changeset} -> Repo.rollback(changeset)
        end
      end)
      # => {:error, %Ecto.Changeset{}} — caller gets actionable validation errors
      ```
      
      The same applies to `Repo.insert/1` and `Repo.delete/1`. For multiple steps, use
      `with ... else` and roll back on the error path:
      
      ```elixir
      Repo.transaction(fn ->
        with {:ok, user}    <- Repo.insert(user_changeset),
             {:ok, profile} <- Repo.insert(profile_changeset(user)) do
          profile
        else
          {:error, changeset} -> Repo.rollback(changeset)
        end
      end)
      ```
      
      Prefer `Repo.transact/1` (above) or `Ecto.Multi` (below) when you can — both
      surface `{:error, ...}` on failure without a manual `Repo.rollback/1`, steering
      you away from the bare match. This footgun is specific to the classic
      `Repo.transaction/1` callback form. (Ties to the plugin Iron Laws on checking
      changeset errors and matching `{:error, %Ecto.Changeset{}}` explicitly.)
      
      ## Ecto.Multi (Complex operations, testing)
      
      ```elixir
      alias Ecto.Multi
      
      # Basic Multi
      Multi.new()
      |> Multi.insert(:user, user_changeset)
      |> Multi.insert(:profile, fn %{user: user} ->
        Profile.changeset(%Profile{user_id: user.id}, %{})
      end)
      |> Multi.run(:welcome_email, fn _repo, %{user: user} ->
        MyApp.Mailer.deliver_welcome(user)
      end)
      |> Repo.transaction()
      
      # Composition with merge
      Multi.new()
      |> Multi.insert(:order, order_changeset)
      |> Multi.merge(fn %{order: order} ->
        Multi.new()
        |> Multi.insert_all(:items, OrderItem, build_items(order))
      end)
      |> Repo.transaction()
      
      # Reusable Multi components (default to Multi.new())
      def transfer_money(multi \\ Multi.new(), from_id, to_id, amount) do
        multi
        |> Multi.run(:validate, fn _, _ -> validate_accounts(from_id, to_id) end)
        |> Multi.update(:debit, fn _ -> debit_changeset(from_id, amount) end)
        |> Multi.update(:credit, fn _ -> credit_changeset(to_id, amount) end)
      end
      
      # Error handling
      case Repo.transaction(multi) do
        {:ok, %{user: user, team: team}} ->
          {:ok, user}
        {:error, :user, changeset, _changes} ->
          {:error, :user_creation_failed, changeset}
        {:error, :team, changeset, _changes} ->
          {:error, :team_creation_failed, changeset}
      end
      
      # Testing Multi without DB
      multi = PasswordManager.reset(account, params)
      assert [{:account, {:update, changeset, []}}] = Ecto.Multi.to_list(multi)
      ```
      
      ## Upsert Patterns
      
      ```elixir
      # Insert or update on conflict
      Repo.insert(
        changeset,
        on_conflict: {:replace, [:name, :updated_at]},
        conflict_target: :external_id
      )
      
      # Insert or do nothing
      Repo.insert(changeset, on_conflict: :nothing, conflict_target: :email)
      
      # Insert all with upsert
      Repo.insert_all(
        Post,
        posts,
        on_conflict: {:replace_all_except, [:id, :inserted_at]},
        conflict_target: :external_id
      )
      ```
      
      ## Batch Operations
      
      ```elixir
      # insert_all (fast bulk insert)
      Repo.insert_all(Post, posts, returning: [:id])
      
      # update_all (fast bulk update)
      from(p in Post, where: p.status == :draft)
      |> Repo.update_all(set: [status: :archived, updated_at: DateTime.utc_now()])
      
      # delete_all (fast bulk delete)
      from(p in Post, where: p.inserted_at < ^cutoff)
      |> Repo.delete_all()
      ```
      
      ## Streaming
      
      ```elixir
      # For processing large result sets without loading all into memory
      Repo.transaction(fn ->
        from(p in Post)
        |> Repo.stream()
        |> Stream.each(&process_post/1)
        |> Stream.run()
      end)
      ```
      
      ## Connection Pool Tuning
      
      ```elixir
      # config/runtime.exs
      config :my_app, MyApp.Repo,
        pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
        queue_target: 50,
        queue_interval: 1000
      ```
      
      Rule of thumb: `pool_size = (CPU cores * 2) + disk spindles`
      
  • SKILL.md 3.4 KB
    ---
    name: ecto-patterns
    description: 'Use when a task touches Ecto, even a how-to question: schemas, changesets,
      validations, queries, preloads, Multi, migrations, constraints, money fields. Load
      it before writing Ecto code. Skip for Ash.'
    ---
    
    # Ecto Patterns Reference
    
    Reference for working with Ecto schemas, queries, and migrations.
    
    ## Iron Laws — Never Violate These
    
    1. **CHANGESETS ARE FOR EXTERNAL DATA** — Use `cast/4` for user/API input, `change/2` or `put_change/3` for internal trusted data
    2. **NEVER USE `:float` FOR MONEY** — Always use `:decimal` or `:integer` (cents)
    3. **NO RAILS-STYLE POLYMORPHIC ASSOCIATIONS** — They break foreign key constraints; use multiple nullable FKs or separate join tables
    4. **ALWAYS PIN VALUES IN QUERIES** — `u.name == ^user_input` is safe, string interpolation causes SQL injection
    5. **PRELOAD COLLECTIONS, NOT INDIVIDUALS** — Preloading in loops = N+1 queries
    6. **CONSTRAINTS BEAT VALIDATIONS FOR RACE CONDITIONS** — Validations provide quick feedback, constraints provide DB-level safety
    7. **SEPARATE QUERIES FOR `has_many`, JOIN FOR `belongs_to`** — Avoids row multiplication
    8. **NO IMPLICIT CROSS JOINS** — `from(a in A, b in B)` without `on:` creates Cartesian product
    9. **DEDUP BEFORE `cast_assoc` WITH SHARED DATA** — When multiple parents share child data, deduplicate child records BEFORE building changesets. Dedup only works within a single changeset
    
    ## Quick Schema Template
    
    ```elixir
    defmodule MyApp.Context.Entity do
      use Ecto.Schema
      import Ecto.Changeset
    
      @primary_key {:id, :binary_id, autogenerate: true}
      @foreign_key_type :binary_id
    
      schema "entities" do
        field :name, :string
        field :status, Ecto.Enum, values: [:draft, :active, :archived]
        field :amount_cents, :integer  # Never :float for money!
        belongs_to :user, MyApp.Accounts.User
        timestamps(type: :utc_datetime_usec)
      end
    
      def changeset(entity, attrs) do
        entity
        |> cast(attrs, [:name, :status, :amount_cents])
        |> validate_required([:name])
        |> foreign_key_constraint(:user_id)
      end
    end
    ```
    
    ## Quick Decisions
    
    ### cast vs put_change vs change
    
    | Function | Use When |
    |----------|----------|
    | `cast/4` | External data (user input, API) |
    | `put_change/3` | Internal trusted data (timestamps, computed) |
    | `change/2` | Internal data from existing struct |
    
    ### Preload Strategy
    
    | Relationship | Strategy |
    |--------------|----------|
    | `belongs_to` | JOIN (single query) |
    | `has_many` | Separate queries (avoid row multiplication) |
    
    ## Common Anti-patterns
    
    | Wrong | Right |
    |-------|-------|
    | `field :amount, :float` | `field :amount_cents, :integer` |
    | `"SELECT * WHERE name = '#{name}'"` | `from(u in User, where: u.name == ^name)` |
    | `Repo.all(User) \|> Enum.filter(& &1.active)` | `from(u in User, where: u.active)` |
    | Preloading in loops | `Repo.preload(posts, :comments)` |
    | `Repo.get!(User, user_id)` with user input | `Repo.get(User, id)` + handle nil |
    | `{:ok, _} = Repo.update(cs)` inside `Repo.transaction` | `case`/`with` + `Repo.rollback(cs)` (see transactions.md) |
    
    ## References
    
    For detailed patterns, see:
    
    - `references/changesets.md` - cast vs put_change, custom validations, prepare_changes
    - `references/queries.md` - Composable queries, dynamic, subqueries, preloading
    - `references/migrations.md` - Safe migrations, concurrent indexes, NOT NULL
    - `references/transactions.md` - Repo.transact, Ecto.Multi, upserts
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related