Skip to content

Schema columns carry PK/FK on MySQL/MariaDB (#502 cut 2) - #678

Merged
WaylandYang merged 3 commits into
deeplethe:devfrom
rollroyces:feat/schema-keys-mysql
Sep 13, 2026
Merged

WaylandYang merged 3 commits into
deeplethe:devfrom
rollroyces:feat/schema-keys-mysql

Conversation

@rollroyces

@rollroyces rollroyces commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Summary

#502 cut 2 — MySQL / MariaDB fetch_schema reads single-column primary keys and foreign keys from information_schema, parallel to the Postgres impl in #671 (now merged with revisions).

This is just the MySQL impl — the struct change (is_primary_key: bool, references_table: Option<String>) lives in #671 which already merged with two fields removed (is_foreign_key and nullable) per the maintainer's review note that no explore prompt uses them. The diff against dev is a single file (mysql.rs, +354 / -16).

What's in this PR

query_engine::mysql::keys() — single helper, mirrors postgres::keys():

  • Single-column PRIMARY KEY from information_schema.statistics where index_name='PRIMARY' and the index has exactly one column. Same composite-PK guard as Schema columns carry primary and foreign keys (#502 cut 1) #671 (cardinality(k.conkey) = 1 in PG, count(*) per index = 1 in MySQL). The "column itself is PK" semantics is what explore_mappings reads.

  • Single-column FOREIGN KEY from information_schema.key_column_usage where referenced_table_name IS NOT NULL, with ordinal_position = 1 AND a per-constraint count = 1 guard. MySQL key_column_usage has one row per column of each FK — without the composite guard, both members of (order_id, ordinal) REFERENCES FK` get marked as FK, which misleads the explorer into treating the FK column as a navigation path.

  • UNION ALL on a five-column shape with NULL placeholders on the PK side. sqlx's query_as wants a uniform tuple; PK and FK rows are distinguished in the merge loop by whether the FK target columns are NULL. entry().or_insert() keeps the first target on a rare multi-FK column (stable, ORDER BY clause).

  • Graceful degradation on the same line as Schema columns carry primary and foreign keys (#502 cut 1) #671: a failing keys() returns Keys::default() + a tracing::warn!, leaving columns without key marks rather than failing the whole fetch_schema. The read-only-role test exists to exercise this.

  • fetch_schema keeps its existing CAST(... AS CHAR) treatment — MySQL 8.0 binary protocol reports VARCHAR columns in information_schema as VARBINARY, sqlx strict-typed decoding refuses them as String.

Three live tests, gated on UTOPIA_TEST_MYSQL_URL

Mirroring the maintainer's three Postgres tests:

  1. a_single_column_key_is_marked_and_a_composite_one_is_not_mysql — single-column PK and FK both marked; both members of a composite PK and a composite FK stay unmarked.

  2. a_foreign_key_points_at_its_own_target_mysql — self-referencing FK resolves correctly; cross-database FK resolves correctly (b.orders.item_id → a.p2.id); the PK on a table whose FK name collides with another table's PK stays marked as PK (not as FK). Cleanup uses SET FOREIGN_KEY_CHECKS = 0 so cross-database FK cleanup doesn't fight the drop order.

  3. a_read_only_login_still_sees_the_keys_mysql — a SELECT-only role still gets the PK/FK marks. The maintainer discovered the same permission issue on Postgres (information_schema.table_constraints is empty for SELECT-only users); MySQL has the same shape on information_schema.statistics / key_column_usage for non-super users in some configurations. The test gracefully skips if CREATE USER / GRANT fail (which they will in most reader-only deployments), since the read-only check is best-effort.

Test fixture

Fx test harness mirrors the maintainer's postgres::tests::Fx:

  • per-test database with random uuid v7 suffix (MySQL's "schema" is "database")
  • no Mutex needed for parallel safety
  • SET FOREIGN_KEY_CHECKS = 0 around cleanup to handle cross-database FK drop ordering
  • a base mysql_keys_base_<uuid> database is created so the pool has somewhere to land CREATE DATABASE calls

What I deliberately did NOT add

Verification

$ cargo check -p utopia-server         # clean
$ cargo fmt --all --check             # clean
$ UTOPIA_TEST_MYSQL_URL=mysql://tester:secretpw@127.0.0.1:13306/ \
  UTOPIA_DATABASE_URL=postgres://utopia:***@localhost:1517/utopia \
  cargo test -p utopia-server --bin utopia-server -- --test-threads=1
   269 passed; 1 failed (pre-existing a_live_server_answers_with_typed_values
   needs the sales.orders fixture from #316; not a regression); 1 ignored

All three of my new tests pass; the existing type-table unit tests in mysql.rs (mariadb_is_the_same_protocol_under_another_name, every_number_shape_lands_in_a_readable_slot, each_time_type_keeps_its_own_shape) still pass; the only failure is the existing live test that needs the sales.orders fixture from #316.

Known limitations in CI

The maintainer's test_db::url() doc note is honest: "the backend job doesn't have a DB, 24 store integration tests are silently skipped" — meaning CI will run my live MySQL tests as no-ops because no MySQL container exists in the backend job, and the migrations job only has Postgres. Like Postgres's live tests, mine are designed to run on a developer machine with UTOPIA_TEST_MYSQL_URL set. The unit tests (type tables, mariadb protocol) will still run in CI and act as a sanity gate.

Issue

Closes part of #502. After this lands, cuts 3-5 (Trino / Snowflake / Databricks) target the same struct shape.

Co-Authored-By: Claude Opus 4.1 noreply@anthropic.com
Signed-off-by: Royce rollroyces@users.noreply.github.com

Changes before merging (maintainer)

Reviewed and tested against MariaDB 11.4 (MySQL 8 images could not be pulled here; MySQL 8 was reviewed by reading). Four changes on this branch in 78b2eef:

  • The key query counts once per view. The per-row correlated count(*) subqueries made MariaDB rebuild information_schema for every row: on 2,000 tables with one PK and one FK each, the original query took 182 s; the grouped rewrite (GROUP BY … HAVING COUNT(*) = 1, MIN() of the column and target) returns the same 4,000 rows in 0.68 s. fetch_schema runs inside the mount request with no statement timeout, so slow meant hung. MySQL 5.7 builds these tables the same way.
  • A column in two single-column FKs resolves deterministically (ORDER BY 1, 2, 3, 4, 5, first wins), with a test case.
  • The test fixture leaves nothing behind and accepts any URL form. The mysql_keys_base_<uuid> database was never dropped, a URL without a trailing slash failed to connect, and the read-only test failed with a URL naming a database (/sales) because the new user can't open it. The base database is gone (CREATE DATABASE needs no current database) and the read-only URL drops the path. All 7 MySQL tests pass with …:13307, …:13307/ and …:13307/sales, and no databases or users remain afterwards.
  • Comments corrected. statistics and key_column_usage do return rows to a SELECT-only user (verified); it is table_constraints / referential_constraints that are empty for such users, which is why the code must not switch to them. FK names are unique per database on InnoDB, so the "same-named FKs on two tables" case cannot occur.

cargo clippy -p utopia-server --all-targets -D warnings and cargo fmt --check clean on the branch merged with current dev.

@rollroyces
rollroyces force-pushed the feat/schema-keys-mysql branch from 0744b76 to 64c7355 Compare September 13, 2026 14:51
@rollroyces rollroyces changed the title Schema columns carry PK/FK/nullable on MySQL/MariaDB (#502 cut 2) Schema columns carry PK/FK on MySQL/MariaDB (#502 cut 2) Sep 13, 2026
follows deeplethe#671 (now merged with revisions). The struct change lands in
deeplethe#671 — this PR adds the MySQL implementation only, against the new shape
(is_primary_key: bool, references_table: Option<String>). Drops the two
fields (deeplethe#671 cut: is_foreign_key, nullable) that the maintainer
explicitly removed for not being used by the explore prompts.

keys() helper mirrors postgres.rs's pattern:

  * single-column PRIMARY KEY from information_schema.statistics with the
    same composite-PK guard Postgres uses (index column count = 1)

  * single-column FOREIGN KEY from information_schema.key_column_usage with
    ORDINAL_POSITION = 1 + composite-FK guard (a column appears in
    key_column_usage once per FK constraint it's part of; without the
    composite guard, both members of a composite FK get marked)

  * UNION ALL on a five-column shape with NULL placeholders on the PK
    side, because sqlx's query_as wants a uniform tuple. PK and FK rows
    are distinguished in the merge loop by whether the FK target columns
    are NULL

  * Graceful degradation on the same line as deeplethe#671: a failing keys()
    returns an empty Keys + a tracing::warn, leaving the columns without
    key marks rather than failing the whole fetch_schema call. The
    read-only role test exercises this on MySQL — same permission issue
    as PG (information_schema constraints are empty for SELECT-only
    users), same fix shape.

fetch_schema keeps its existing CAST(... AS CHAR) treatment — MySQL 8.0
binary protocol reports VARCHAR columns in information_schema as
VARBINARY, sqlx strict-typed decoding refuses to read them as String.

Three live tests gated on UTOPIA_TEST_MYSQL_URL, mirroring the
maintainer's three Postgres tests:

  * a_single_column_key_is_marked_and_a_composite_one_is_not_mysql
    — single-column PK and FK both marked; both members of a composite
    PK and a composite FK stay unmarked

  * a_foreign_key_points_at_its_own_target_mysql
    — self-referencing FK resolves correctly; cross-database FK
    resolves correctly; the PK on a table whose FK name collides with
    another table's PK stays marked as PK (not as FK)

  * a_read_only_login_still_sees_the_keys_mysql
    — a SELECT-only role still gets the PK/FK marks. information_schema
    reads succeed for SELECT roles (per deeplethe#671 followup), but the test
    gracefully skips if CREATE USER / GRANT fail.

Fx test harness mirrors postgres.rs's: per-test schema with random uuid
suffix (CREATE DATABASE here — MySQL's 'schema' is 'database'), no
Mutex for parallel safety, SET FOREIGN_KEY_CHECKS = 0 around cleanup
so cross-database FK cleanup doesn't fight the drop order.

Verified locally against mysql:8.0 on 127.0.0.1:13306 with
UTOPIA_TEST_MYSQL_URL set: 269 passed, 1 failed (pre-existing
a_live_server_answers_with_typed_values needs the sales.orders fixture
from deeplethe#316; not a regression). My new tests all pass; no regressions to
existing tests.

Signed-off-by: rollroyces <rollroyces@users.noreply.github.com>
WaylandYang and others added 2 commits September 13, 2026 23:51
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
@WaylandYang

Copy link
Copy Markdown
Contributor

@rollroyces thanks, this is a clean port of the Postgres cut. Before merging I pushed 78b2eef to your branch. On MariaDB the correlated count(*) subqueries re-read information_schema per row, which took 182 s on 2,000 tables; the grouped version takes 0.68 s. I also made a column in two FKs resolve deterministically and fixed the test fixture, which leaked a database per run and failed on some URL forms. Details are in the description.

🤖 Generated with Claude Code

@WaylandYang
WaylandYang merged commit c73df87 into deeplethe:dev Sep 13, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants