PhoenixKit.Migrations.Repair.Probe (phoenix_kit v2.0.0)

Copy Markdown View Source

All read-only introspection against the target server: the raw version comment (task rule (a): never PhoenixKit.Migrations.Postgres.migrated_version/1's legacy no-comment→1 mapping — see below), a structural catalog snapshot mirroring PhoenixKit.Squash.Generate.Catalog's queries (dev_docs/squash/ generate_baseline.exs), and the small pure helpers that read an PhoenixKit.Migrations.ExpectedSchema.Object.t()'s check field against that snapshot.

Every function that takes a repo executes real queries and has no unit test (this repo's suite is DB-free); lookup/2 and presence_by_since/2 take an already-fetched snapshot and are pure — those are the ones test/phoenix_kit/migrations/repair/probe_test.exs exercises directly with hand-built snapshots.

Raw comment read — why this duplicates Postgres.migrated_version/1's SQL instead of calling it

Postgres.migrated_version/1 (postgres.ex:1373-1410) runs the exact same two queries this module's raw_comment/2 does (table-exists, then obj_description), but its last clause folds "table exists, no comment" into 1 ("Table exists but no version comment - assume version 1 (legacy V01 installation)") — the right behavior for a migration entry point deciding what to run next, and exactly the wrong behavior for PhoenixKit.Migrations.Repair: spec §6.4 R4 needs to tell "genuinely at V1" apart from "half-installed/adopted, comment stripped" (§6.1: "that mapping would swallow the half-installed/adopted case into the below-floor error"). There is no way to get the un-folded value out of migrated_version/1 — its case has no branch that returns it — so raw_comment/2 re-implements the same two queries and stops one line earlier.

Summary

Types

:absent (table missing), nil (table exists, no comment), or the numeric comment.

One schema's structural snapshot — mirrors PhoenixKit.Squash.Generate.Catalog.snapshot/2's shape, minus seeds (see moduledoc "Seeds" note on PhoenixKit.Migrations.Repair.Differ).

Functions

Looks up the observed shape for one {:catalog, spec} check within a snapshot/2 result. nil means absent (the caller treats that as :missing). Pure — the one function in this module with a direct unit test (probe_test.exs builds a snapshot by hand).

Counts rows in table whose fk_column does not match any row in foreign_table.foreign_column — the orphan diagnostic spec §6.3 attaches to a failed FK VALIDATE CONSTRAINT (leave the constraint NOT VALID, report the count rather than guessing a fix). All four identifiers are validated ([a-zA-Z_][a-zA-Z0-9_]*) before interpolation — they come from the catalog snapshot, not user input, but this executes as a plain string query, not a parameterized one, because table/column names cannot be bind parameters in SQL.

For each distinct since among objects, whether every object introduced at that version is structurally present in snapshot — the input PhoenixKit.Migrations.Repair.CommentPolicy.highest_fully_present_version/1 consumes. :seed-class and :legacy_optional objects are excluded from the computation (never made to count against a version being "fully present"): seed presence is a data-completeness question, not a schema marker (best-effort seeders are "NOT guaranteed present" by design), and :legacy_optional presence is bimodal by design (spec §3.7) — either state is normal, so neither should ever cause a version to read as "behind".

The raw version comment — see moduledoc. Never raises; any query failure (including an invalid prefix reaching the DB, which should not happen — callers validate first) is treated the same as "table absent".

Executes an already-materialized :seed check SQL string (SELECT EXISTS (...)).

The connected server's Postgres major version (SHOW server_version_num's leading digits), for §6.3's preflight.

A full structural snapshot of prefix on the target server — one round trip per class (7 queries total), mirroring PhoenixKit.Squash.Generate.Catalog.snapshot/2's shape exactly (minus seeds — see PhoenixKit.Migrations.Repair.Differ's moduledoc for why seed rows are checked for presence only, never snapshotted for value comparison). oban_* tables/sequences/functions and schema_migrations are excluded — Oban is delegated, never manifested (spec §6.1).

Types

raw_comment()

@type raw_comment() :: :absent | nil | :unparseable | non_neg_integer()

:absent (table missing), nil (table exists, no comment), or the numeric comment.

snapshot()

@type snapshot() :: %{
  tables: %{required(String.t()) => map()},
  columns: %{required({String.t(), String.t()}) => map()},
  indexes: %{required(String.t()) => map()},
  constraints: %{required({String.t(), String.t()}) => map()},
  sequences: %{required(String.t()) => map()},
  functions: %{required({String.t(), String.t()}) => map()},
  extensions: %{required(String.t()) => map()}
}

One schema's structural snapshot — mirrors PhoenixKit.Squash.Generate.Catalog.snapshot/2's shape, minus seeds (see moduledoc "Seeds" note on PhoenixKit.Migrations.Repair.Differ).

Functions

lookup(snapshot, check)

Looks up the observed shape for one {:catalog, spec} check within a snapshot/2 result. nil means absent (the caller treats that as :missing). Pure — the one function in this module with a direct unit test (probe_test.exs builds a snapshot by hand).

iex> snapshot = %{tables: %{"widgets" => %{}}, columns: %{}, indexes: %{},
...>   constraints: %{}, sequences: %{}, functions: %{}, extensions: %{}}
iex> Probe.lookup(snapshot, {:catalog, %{kind: :table, name: "widgets"}})
%{}
iex> Probe.lookup(snapshot, {:catalog, %{kind: :table, name: "missing"}})
nil

orphan_count(repo, prefix, table, fk_column, foreign_table, foreign_column)

@spec orphan_count(
  Ecto.Repo.t(),
  String.t(),
  String.t(),
  String.t(),
  String.t(),
  String.t()
) ::
  non_neg_integer() | :unknown

Counts rows in table whose fk_column does not match any row in foreign_table.foreign_column — the orphan diagnostic spec §6.3 attaches to a failed FK VALIDATE CONSTRAINT (leave the constraint NOT VALID, report the count rather than guessing a fix). All four identifiers are validated ([a-zA-Z_][a-zA-Z0-9_]*) before interpolation — they come from the catalog snapshot, not user input, but this executes as a plain string query, not a parameterized one, because table/column names cannot be bind parameters in SQL.

presence_by_since(objects, snapshot)

@spec presence_by_since([PhoenixKit.Migrations.ExpectedSchema.Object.t()], snapshot()) ::
  [
    {pos_integer(), boolean()}
  ]

For each distinct since among objects, whether every object introduced at that version is structurally present in snapshot — the input PhoenixKit.Migrations.Repair.CommentPolicy.highest_fully_present_version/1 consumes. :seed-class and :legacy_optional objects are excluded from the computation (never made to count against a version being "fully present"): seed presence is a data-completeness question, not a schema marker (best-effort seeders are "NOT guaranteed present" by design), and :legacy_optional presence is bimodal by design (spec §3.7) — either state is normal, so neither should ever cause a version to read as "behind".

Grouping happens on the RAW since first, filtering only within each group — never Enum.filter/2 before Enum.group_by/2. A since bucket whose objects are entirely seeds/:legacy_optional (e.g. a version that only ever added a seed row) still gets an entry here, {since, true} (Enum.all?/2 on the empty, fully-filtered list is vacuously true) — dropping such a bucket from the result instead of reporting it present would happen to be harmless for CommentPolicy.highest_fully_present_version/1 today (take_while over a sorted list tolerates a missing entry exactly like a true one), but this function's own contract — "for each distinct since among objects" — promises an entry for every one, and a future caller should not have to know that gap-tolerance to rely on it correctly.

raw_comment(repo, prefix)

@spec raw_comment(Ecto.Repo.t(), String.t()) :: raw_comment()

The raw version comment — see moduledoc. Never raises; any query failure (including an invalid prefix reaching the DB, which should not happen — callers validate first) is treated the same as "table absent".

seed_present?(repo, check_sql)

@spec seed_present?(Ecto.Repo.t(), String.t()) :: boolean()

Executes an already-materialized :seed check SQL string (SELECT EXISTS (...)).

server_version_major(repo)

@spec server_version_major(Ecto.Repo.t()) :: pos_integer() | :unknown

The connected server's Postgres major version (SHOW server_version_num's leading digits), for §6.3's preflight.

snapshot(repo, prefix)

@spec snapshot(Ecto.Repo.t(), String.t()) :: snapshot()

A full structural snapshot of prefix on the target server — one round trip per class (7 queries total), mirroring PhoenixKit.Squash.Generate.Catalog.snapshot/2's shape exactly (minus seeds — see PhoenixKit.Migrations.Repair.Differ's moduledoc for why seed rows are checked for presence only, never snapshotted for value comparison). oban_* tables/sequences/functions and schema_migrations are excluded — Oban is delegated, never manifested (spec §6.1).

Why this forces search_path = '' for the duration of the snapshot

pg_get_expr/pg_get_indexdef/pg_get_constraintdef (used by the columns/indexes/constraints queries below to render defaults, predicates, and definitions) decide at read time, from the QUERYING session's search_path, whether to schema-qualify a referenced function/type — never from how the object was originally created. The manifest's expected shapes are captured by the generator (PhoenixKit.Squash.Generate.Catalog.snapshot/2, dev_docs/squash/ generate_baseline.exs) from a scratch schema its own validate_schemas!/1 refuses to let be named "public" — specifically so that schema is never on the generation connection's default searchpath — so the generator's captured text is (by that constraint) always schema-qualified, then token-substituted for "__SCHEMA__" (DumpHelper.substitute_schema/2). A "public"-prefix install's OWN default search_path normally DOES include public, so without this, the exact same stored default (e.g. every uuid_generate_v7()-defaulted column — nearly every primary/ foreign key in the chain) would read back UNQUALIFIED here while the materialized manifest expects it QUALIFIED, manufacturing a false :wrong_shape divergence (PhoenixKit.Migrations.Repair.Differ) on the single most common install topology, on very common objects, for reasons having nothing to do with real drift. Forcing search_path = '' here — the same idiom pg_dump itself relies on for portable, fully-qualified output — makes this side schema-qualify exactly as consistently as the generator's capture does, independent of which prefix is being probed. pg_catalog (every query below is a pg_catalog/`pg*read) stays resolvable regardless: Postgres always searches it first, whether or not it is named insearch_path`.

Runs inside repo.checkout/2 so the SET, every catalog query, and the final RESET land on the SAME physical connection — Ecto/DBConnection checkout nests transparently, so this is equally safe called from inside PhoenixKit.Migrations.Repair.Environment.with_lock/2's own outer checkout (the common, direct-connection path) or as the outermost checkout (the --unsafe-pooled path, which skips the lock). The after clause restores search_path unconditionally, even if a catalog query raises — this is session-level state on a connection this process borrows from a pool it shares with the rest of the host application; leaving it at '' would silently break any later unqualified query issued by anything else on that physical connection once it is checked back in. RESET search_path (never a literal previous-value SET) correctly restores the default for every deployment this tool documents supporting — PhoenixKit's own runtime queries never rely on search_path for a prefixed install (CLAUDE.md: "no search_path requirement on the DB role"), so nothing in this codebase's own connections customizes it at connect time for a RESET to lose.