Skip to content

perf(Schema): bulk table introspection (getSchemas) - #265

Open
roxblnfk wants to merge 7 commits into
2.xfrom
feat/bulk-introspection
Open

perf(Schema): bulk table introspection (getSchemas)#265
roxblnfk wants to merge 7 commits into
2.xfrom
feat/bulk-introspection

Conversation

@roxblnfk

@roxblnfk roxblnfk commented Aug 14, 2026

Copy link
Copy Markdown
Member

🔍 What was changed

Introduce bulk schema introspection so a set of tables can be read together instead of one full introspection per table.

  • New BulkSchemaProviderInterface::getSchemas(array $tables, ?string $prefix), implemented by the base Handler and exposed through the new Database::getSchemas(?array $tables = null).
  • Postgres and SQLServer batch the catalog reads: the per-table WHERE table_name = ? filters become IN (...), grouped back per table in PHP. SQLServer foreign keys, normally one sp_fkeys call per table (not batchable), are read for the whole set from sys.foreign_keys.
  • MySQL and SQLite keep introspecting table by table for now (the base Handler loop). SQLite is embedded so batching buys nothing; MySQL needs a SHOWinformation_schema migration first, tracked as a follow-up spec.
  • The table list is mandatory by design — the queries never read catalog rows of tables the caller did not ask for; the "whole database" case is Database::getSchemas(null), which resolves the names first and then feeds them in.

How it works

  • AbstractTable gains an internal prefetch path: given the raw rows it builds the schema through the same createInstance() factories as the per-table path, so the two results are identical by construction.
  • Existence is derived from a table being present in the batched result — it replaces the per-table hasTable() query; a table that does not exist yet comes back as an empty STATUS_NEW schema rather than being skipped.
  • The prefetched rows are consumed once during the initial load and dropped, so any later re-introspection (e.g. the post-save reload) hits the database as usual.

Why?

Continues the introspection-performance work tracked in cycle/orm#466 ("Cycle schema hydration is too slow"). Per-table query batching already removed the N+1 within a single table, but a schema of N tables still costs N × ~5 sequential round-trips, which dominates bootstrap on a remote database at ~100 ms latency. Bulk introspection collapses that to a constant number of queries for a given set of tables. The cycle/schema-builder consumer (lazy, batched loading via Database::getSchemas()) lands in a separate PR there.

Checklist

  • How was this tested:
    • Tested manually — full driver suites + the new BulkIntrospectionTest run locally against Postgres, MySQL, SQL Server and SQLite (Docker), green with no expectation changes.
    • Unit tests added — BulkIntrospectionTest across all four drivers: per-table ↔ bulk identity (assertSameAsInDB), composite PK/FK/index ordering, missing tables → STATUS_NEW, prefix handling, ReadonlyHandler delegation, and (Postgres/SQLServer) query count independent of the number of tables.

Review notes

  • The SQLServer foreign-key rewrite emits the referential action with sp_fkeys semantics (0 = CASCADE, non-zero = NO ACTION) so SQLServerForeignKey::createInstance() stays untouched — sys.foreign_keys uses the inverse encoding.
  • Public API is unchanged and backward compatible; getSchemas() is additive.

Introduce BulkSchemaProviderInterface::getSchemas(array $tables, ?string $prefix) so a set of tables can be introspected together instead of one full introspection per table. The table list is mandatory by design — the queries must never read catalog rows of tables the caller did not ask for; the "whole database" case is the explicit Database::getSchemas(null) path that first resolves names via getTableNames(). The base Handler ships a per-table-loop implementation (correct for every driver) that batching drivers override; ReadonlyHandler delegates. AbstractTable gains an internal prefetch construction path: when raw rows are supplied it derives existence from their presence (no hasTable() query) and drops the payload after the initial load so any later re-introspection still hits the database.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Override getSchemas() to read the whole set of tables with a constant number of queries. Every query is the per-table one widened from `table_schema = ? AND table_name = ?` to a row-value `IN (...)` list, grouped back per table in PHP and injected into PostgresTable via the prefetch path. Because the SQL and the createInstance factories are unchanged, the bulk result is identical to getSchema() by construction. The pair list is chunked so the bound parameter count stays bounded on any set size.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Override getSchemas() to introspect the whole set with a constant number of queries: the per-table `table_name = ?` filters become `table_name IN (...)`, and DEFAULT/CHECK constraints are read once for all object ids (their keys are database-wide unique). Foreign keys — normally one sp_fkeys call per table, which cannot be batched — are read for the whole set from sys.foreign_keys in a shape that matches the sp_fkeys rows SQLServerForeignKey consumes, with the referential action mapped back to sp_fkeys semantics (0 = CASCADE, non-zero = NO ACTION).

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Assert, per driver, that getSchemas() returns a schema identical to the per-table getSchema() (via assertSameAsInDB), that non-existent tables come back as empty STATUS_NEW schemas rather than being skipped, that the prefix is honored, and that Database::getSchemas() works both with an explicit list and for the whole database. Composite primary keys, foreign keys and indexes are covered specifically because that is where the batched queries rely on ORDER BY to reproduce the per-table column order. ReadonlyHandler delegation is covered too. Batching drivers (Postgres, SQLServer) additionally assert the query count does not grow with the number of tables; loop drivers (MySQL, SQLite) skip that check.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.52632% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.50%. Comparing base (8dcf9dc) to head (6c3b4ab).

Files with missing lines Patch % Lines
src/Driver/Postgres/PostgresHandler.php 88.37% 15 Missing ⚠️
src/Driver/SQLServer/SQLServerHandler.php 99.35% 1 Missing ⚠️
src/Driver/SQLServer/Schema/SQLServerTable.php 96.42% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##                2.x     #265      +/-   ##
============================================
+ Coverage     95.48%   95.50%   +0.02%     
- Complexity     2119     2209      +90     
============================================
  Files           141      142       +1     
  Lines          5974     6312     +338     
============================================
+ Hits           5704     6028     +324     
- Misses          270      284      +14     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Without an explicit ORDER BY, Postgres and SQL Server return catalog rows in planner-dependent order once several tables are fetched in one query, so bulk-introspected tables could get a column order different from the per-table path (varchar columns drifting to the end). Order columns by ordinal position and Postgres index rows by name.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
assertSameAsInDB matches columns by name, so the ordering regression fixed in the previous commit was invisible to the existing identity tests. The new case introspects 30 tables with interleaved column types — enough rows for the Postgres planner to prefer a hash join, which is what scrambled the order — and compares the exact column order against the per-table path.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
Add DB-free unit tests for the two defensive fallbacks that the functional suite cannot reach, since every built-in handler implements BulkSchemaProviderInterface: ReadonlyHandler and Database::getSchemas() both loop over getSchema() when the wrapped handler does not support bulk introspection. Mocking a plain HandlerInterface (which is not a BulkSchemaProviderInterface) exercises exactly those branches.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a bulk schema introspection API to reduce round-trips by fetching schema metadata for multiple tables in one batched operation (where supported), while preserving parity with existing per-table introspection behavior.

Changes:

  • Added BulkSchemaProviderInterface::getSchemas(array $tables, ?string $prefix) and a default per-table fallback implementation in the base Handler.
  • Implemented batched introspection for Postgres and SQL Server using IN (...) catalog queries and a PrefetchedIntrospection carrier consumed by AbstractTable during initial load.
  • Added Database::getSchemas(?array $tables = null) plus functional/unit tests validating bulk↔per-table identity, missing-table semantics, prefix handling, and query-count behavior.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/Database/Unit/Driver/ReadonlyHandlerTest.php Adds unit coverage for ReadonlyHandler::getSchemas() fallback behavior when parent lacks bulk support.
tests/Database/Unit/DatabaseTest.php Adds unit coverage for Database::getSchemas() fallback behavior and prefix application.
tests/Database/Functional/Driver/Common/Schema/BulkIntrospectionTest.php Introduces shared functional assertions for bulk introspection identity, ordering, missing tables, prefix, and query counts.
tests/Database/Functional/Driver/Postgres/Schema/BulkIntrospectionTest.php Enables the shared bulk introspection suite for Postgres and asserts it is batched.
tests/Database/Functional/Driver/SQLServer/Schema/BulkIntrospectionTest.php Enables the shared bulk introspection suite for SQL Server and asserts it is batched.
tests/Database/Functional/Driver/MySQL/Schema/BulkIntrospectionTest.php Enables the shared bulk introspection suite for MySQL (non-batched path).
tests/Database/Functional/Driver/SQLite/Schema/BulkIntrospectionTest.php Enables the shared bulk introspection suite for SQLite (non-batched path).
src/Schema/PrefetchedIntrospection.php Adds an internal carrier for per-table raw introspection buckets produced by bulk queries.
src/Schema/AbstractTable.php Adds a prefetch-based initialization path to build schemas without per-table queries and then discard prefetched rows.
src/Driver/BulkSchemaProviderInterface.php Adds the new bulk-introspection interface contract and documentation.
src/Driver/Handler.php Implements BulkSchemaProviderInterface with a default per-table loop.
src/Driver/HandlerInterface.php Updates docblocks/return typing for table-name listing.
src/Driver/ReadonlyHandler.php Implements bulk introspection delegation with a safe per-table fallback when the wrapped handler isn’t a bulk provider.
src/Driver/Postgres/PostgresHandler.php Implements batched Postgres catalog reads and groups rows into per-table prefetch buckets.
src/Driver/Postgres/Schema/PostgresTable.php Consumes prefetched buckets for columns, references, and index rows when present.
src/Driver/SQLServer/SQLServerHandler.php Implements batched SQL Server catalog reads (including FKs via sys.foreign_keys) and prepares per-table prefetch buckets.
src/Driver/SQLServer/Schema/SQLServerTable.php Consumes prefetched buckets for columns/indexes/FKs/PKs when present.
src/Database.php Exposes Database::getSchemas(?array $tables = null) and the “whole database” resolution path when called with no list.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +22 to +24
* "all tables of the database" scenario is an explicit path in
* {@see \Cycle\Database\Database::getTables()} that first resolves the names via
* {@see HandlerInterface::getTableNames()} and then passes them here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants