6.0 KiB
name, description
| name | description |
|---|---|
| sqlite-optimization | Use this skill when writing, reviewing, or optimizing ANY Ecto query, SQL fragment, migration, or database index. Use PROACTIVELY when the user mentions "query", "SQL", "database", "index", "migration", "FTS", "full-text search", "json_extract", "json_each", "GROUP BY", "subquery", "performance", "slow query", "N+1", "Ecto query", "Repo.all", "Repo.one", or asks about database design. Also use when adding WHERE clauses, JOINs, or changing how data is fetched. The SQLite-specific patterns in this project are easy to miss and cause silent performance regressions. |
SQLite Optimization
Project-specific SQLite query patterns, index strategies, and migration conventions. These rules are load-bearing — dev datasets are small enough to hide problems that degrade production performance.
Checklist Before Writing Any Database Query
-
Check for existing expression indexes before writing a query. Your
WHEREorGROUP BYclause must match the index expression textually. -
If no index exists for your query, add one in a migration with an
up/downand a comment explaining which query it helps. -
FTS5 tables are trigger-synced — never INSERT/UPDATE/DELETE directly on
records_search_index. Write torecordsinstead. -
Config-driven constants. Pagination defaults and magic numbers live in
config/config.exs, read viaApplication.compile_env!/2.
Expression Indexes & SQL Text Matching
SQLite treats json_extract(column, '$.path') and column ->> '$.path' as
semantically equal but textually distinct. If an expression index uses
json_extract, your query MUST use the same text:
# Index definition (in migration):
# CREATE INDEX idx_records_format ON records(json_extract(data, '$.format'));
# CORRECT — matches index text
from(r in Record, where: fragment("json_extract(?, '$.format')", r.data) == "vinyl")
# WRONG — won't use the index
from(r in Record, where: fragment("? ->> '$.format'", r.data) == "vinyl")
Same applies to GROUP BY — match the expression index exactly:
# Index exists on: json_extract(r.data, '$.artist_name')
# CORRECT GROUP BY:
from(r in Record,
group_by: fragment("json_extract(?, '$.artist_name')", r.data)
)
Subquery Materialization with limit: -1
When a date-filtered subquery feeds an outer GROUP BY, SQLite may flatten the
subquery and prefer the wrong composite index. Force materialization with
limit: -1:
filtered = from(t in Track,
where: t.scrobbled_at >= ^start_date,
limit: -1 # forces materialization, preserves range-scan index
)
from(t in subquery(filtered),
group_by: t.artist_name,
select: %{artist: t.artist_name, count: count()}
)
|> Repo.all()
Correlated Scalar Subqueries
For small-LIMIT result enrichment from large lookup tables, use correlated scalar
subqueries instead of LEFT JOIN. Cost scales with the outer LIMIT, not the lookup
table size:
# CORRECT — cost proportional to LIMIT
from(r in Record,
limit: 20,
select: %{
id: r.id,
title: r.title,
scrobble_count: fragment(
"(SELECT count(*) FROM scrobbled_tracks WHERE record_id = ?)", r.id
)
}
)
# WRONG — joins entire scrobbled_tracks table
from(r in Record,
left_join: s in subquery(counts_query), on: s.record_id == r.id,
limit: 20
)
FTS5 Full-Text Search
The records_search_index table is an FTS5 virtual table auto-synced via database
triggers. Treat it as read-only:
# CORRECT — insert into the source table, triggers sync FTS
Repo.insert!(%Record{title: "Dark Side of the Moon", ...})
# CORRECT — read from FTS index
from(ri in "records_search_index",
where: fragment("records_search_index MATCH ?", ^query)
)
# WRONG — never write directly to FTS
Repo.insert_all("records_search_index", [...])
JSON Column Patterns
json_each() for expanding arrays
from(r in Record,
cross_join: fragment("json_each(?)", r.genres),
where: fragment("value = ?", ^genre),
select: r
)
json_extract() for field access
from(r in Record,
where: fragment("json_extract(?, '$.format') = ?", r.data, ^format)
)
Materialized Views via Triggers
SQLite lacks native materialized views. Use explicit up/down in migrations for
non-reversible DDL. Read-only schemas for materialized/view tables:
# Read-only schema (no PK, no changeset, no timestamps)
defmodule Records.RecordRelease do
use Ecto.Schema
@primary_key false
schema "record_releases" do
field :record_id, :binary_id
field :release_id, :string
field :cover_hash, :string
field :purchased_at, :utc_datetime
end
end
Migration Conventions
Every migration must:
- Provide both
upanddownfor anyexecuteblock - Comment every index explaining which query it helps
- Use explicit triggers for materialized views
def change do
execute(
# UP
"CREATE INDEX idx_records_purchase_date ON records(purchased_at);",
# DOWN — always provided
"DROP INDEX IF EXISTS idx_records_purchase_date;"
)
end
Query Patterns to Avoid
| Anti-pattern | Why | Fix |
|---|---|---|
Repo.all() then Enum.filter() |
Loads entire table into memory | Filter in SQL with where: |
Enum.map() with inline Repo calls |
N+1 queries | Use preload or joins |
preload: [:association] on large result sets |
Cartesian product in memory | Use join: and select: |
Dynamic order_by without corresponding index |
Full table sort | Add composite index matching sort columns |
distinct: true without index |
Temporary B-tree | Add index on distinct columns |
Using QueryReporter to Verify
Before committing a query change, use the query-reporter skill to capture the actual SQL and verify:
- The query uses the expected indexes (check
EXPLAIN QUERY PLAN) - No unexpected full table scans
- Subqueries are materialized when needed
# In SQLite console (mise run dev:sqlite-console):
EXPLAIN QUERY PLAN SELECT ...;