From e7add3b3a4e1d6934c0c681b833b0ce6ff522a7b Mon Sep 17 00:00:00 2001 From: Mohamad Shwaeki Date: Sun, 30 Aug 2026 22:28:16 +0300 Subject: [PATCH] Support Laravel 10 without taking on doctrine/dbal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Laravel 11 added Schema::getColumns() and Schema::getIndexes(), which read the schema natively; Laravel 10 has neither, and the documented reason for excluding 10 was that supporting it would mean carrying doctrine/dbal for one release line. It does not have to. SchemaIntrospector uses the native methods when they exist and otherwise runs the same driver queries itself — pragma table_info and index_list on SQLite, information_schema on MySQL, MariaDB and SQL Server, information_schema plus pg_index on PostgreSQL — returning the subset of the Laravel 11 array shape the metadata engine consumes. So the engine cannot tell which framework version it is on, and dbal is still never pulled in, on any version. The rest is constraints and matrix. Testbench 8 pins PHPUnit 10, which Pest 3 and 4 do not support, so the Laravel 10 leg installs Pest 2; PHP 8.4 is excluded from it because Laravel 10 was never certified against it. Laravel 10's security window has closed the same way Laravel 11's has, so it joins the advisory-policy exemption. PHP 8.2 stays the floor: Laravel 10 also runs on 8.1, but 8.1 is end of life. Verified on both ends — the full suite passes against Laravel 13 as before, and against laravel/framework 10.x with Testbench 8 and Pest 2. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E4q6Ei7sWPScqPZoF7ySnv --- .github/workflows/tests.yml | 27 +- CHANGELOG.md | 10 + README.md | 2 +- composer.json | 16 +- docs/architecture.md | 12 +- docs/installation.md | 2 +- phpunit.xml | 2 + src/Metadata/MetadataEngine.php | 13 +- src/Support/SchemaIntrospector.php | 347 ++++++++++++++++++++++++++ tests/Unit/SchemaIntrospectorTest.php | 57 +++++ 10 files changed, 461 insertions(+), 27 deletions(-) create mode 100644 src/Support/SchemaIntrospector.php create mode 100644 tests/Unit/SchemaIntrospectorTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c8cb3d6..a70772f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,8 +13,11 @@ jobs: fail-fast: false matrix: php: ['8.2', '8.3', '8.4'] - laravel: ['11.*', '12.*', '13.*'] + laravel: ['10.*', '11.*', '12.*', '13.*'] include: + - laravel: '10.*' + testbench: '8.*' + pest: '^2.0' - laravel: '11.*' testbench: '9.*' - laravel: '12.*' @@ -25,6 +28,9 @@ jobs: # Laravel 13 requires PHP 8.3. - php: '8.2' laravel: '13.*' + # Laravel 10 predates PHP 8.4 and was never certified against it. + - php: '8.4' + laravel: '10.*' name: PHP ${{ matrix.php }} · Laravel ${{ matrix.laravel }} @@ -37,15 +43,20 @@ jobs: extensions: dom, curl, libxml, mbstring, zip, pdo, pdo_sqlite, sqlite3 coverage: none - # Laravel 11's security window closed in March 2026, so Composer will not - # load any 11.x release at all. The package still supports Laravel 11 and - # this leg still proves it, but the framework it tests against is - # unpatched — which is Composer's point, and why this is scoped to the one - # leg that needs it rather than written into composer.json. - - name: Allow Laravel 11's security advisories - if: matrix.laravel == '11.*' + # The security windows for Laravel 10 and 11 have closed, so Composer will + # not load any 10.x or 11.x release at all. The package still supports + # both and these legs still prove it, but the frameworks they test against + # are unpatched — which is Composer's point, and why this is scoped to the + # legs that need it rather than written into composer.json. + - name: Allow the unpatched framework lines' security advisories + if: matrix.laravel == '10.*' || matrix.laravel == '11.*' run: composer config --global policy.advisories.block false + # Testbench 8 pins PHPUnit 10, which Pest 3 and 4 do not support. + - name: Pin Pest to the PHPUnit 10 line + if: matrix.pest != '' + run: composer require --dev "pestphp/pest:${{ matrix.pest }}" --no-interaction --no-update + - name: Install dependencies run: | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update diff --git a/CHANGELOG.md b/CHANGELOG.md index e5a511a..53e1437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to Laravel DynamicTable are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Laravel 10 support. `Schema::getColumns()` and `Schema::getIndexes()` are + Laravel 11 additions, so the new `Support\SchemaIntrospector` uses them when + they exist and otherwise runs the equivalent driver queries (SQLite, MySQL, + MariaDB, PostgreSQL, SQL Server) itself. `doctrine/dbal` is still never + required. The test matrix now covers Laravel 10 on PHP 8.2 and 8.3. + ## [1.0.0] — 2026-08-30 First public release. diff --git a/README.md b/README.md index 37c9cae..22217d3 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Full checklist in [docs/security.md](docs/security.md). ## Requirements -PHP 8.2+ · Laravel 11, 12 or 13 · MySQL, MariaDB, PostgreSQL or SQLite. +PHP 8.2+ · Laravel 10, 11, 12 or 13 · MySQL, MariaDB, PostgreSQL or SQLite. No Livewire. No Alpine. No jQuery. No npm. No CSS framework required. (It works happily *inside* a Livewire app — it just doesn't need one. The diff --git a/composer.json b/composer.json index 88044cf..89cc89d 100644 --- a/composer.json +++ b/composer.json @@ -26,17 +26,17 @@ "source": "https://github.com/shwaeki/DynamicTable" }, "require": { - "illuminate/contracts": "^11.0|^12.0|^13.0", - "illuminate/database": "^11.0|^12.0|^13.0", - "illuminate/http": "^11.0|^12.0|^13.0", - "illuminate/support": "^11.0|^12.0|^13.0", - "illuminate/view": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^10.0|^11.0|^12.0|^13.0", "php": "^8.2", - "symfony/finder": "^7.0|^8.0" + "symfony/finder": "^6.2|^7.0|^8.0" }, "require-dev": { - "orchestra/testbench": "^9.0|^10.0|^11.0", - "pestphp/pest": "^3.0|^4.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "pestphp/pest": "^2.0|^3.0|^4.0", "larastan/larastan": "^2.9|^3.0", "laravel/pint": "^1.18" }, diff --git a/docs/architecture.md b/docs/architecture.md index 367f9bf..48d6871 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -165,11 +165,13 @@ dropped, a warning is surfaced, and the table still renders. ## 9. Supported versions -- **PHP 8.2+** — matches the floor of every currently supported Laravel release. -- **Laravel 11, 12 and 13.** Laravel 11 is the floor because the package relies - on `Schema::getColumns()`, which removed the `doctrine/dbal` requirement for - schema introspection. Supporting Laravel 10 would mean carrying dbal for one - release line; that cost is not worth paying. +- **PHP 8.2+.** Laravel 10 itself also runs on PHP 8.1, but 8.1 is end of life, + so the package does not carry it. +- **Laravel 10, 11, 12 and 13.** Laravel 11 introduced `Schema::getColumns()` + and `Schema::getIndexes()`, which read the schema natively instead of through + `doctrine/dbal`. Rather than require dbal on Laravel 10, `SchemaIntrospector` + uses those methods when they exist and otherwise runs the same driver queries + itself — so the package never pulls dbal in, on any version. - Databases: MySQL, MariaDB, PostgreSQL, SQLite. No database-specific SQL is emitted; `LIKE` is used for search because it is the portable option, and the docs explain when to reach for a real search index instead. diff --git a/docs/installation.md b/docs/installation.md index 6ef80f9..13a78e5 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -5,7 +5,7 @@ | | | |---|---| | PHP | 8.2+ | -| Laravel | 11, 12 or 13 | +| Laravel | 10, 11, 12 or 13 | | Database | MySQL, MariaDB, PostgreSQL or SQLite | | JavaScript build step | none | | CSS framework | none required | diff --git a/phpunit.xml b/phpunit.xml index 8c9c1ed..8863e35 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,6 +18,8 @@ + + diff --git a/src/Metadata/MetadataEngine.php b/src/Metadata/MetadataEngine.php index 212e9ad..a9bf87e 100644 --- a/src/Metadata/MetadataEngine.php +++ b/src/Metadata/MetadataEngine.php @@ -7,11 +7,11 @@ use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use ReflectionClass; use ReflectionMethod; use ReflectionNamedType; +use Shwaeki\DynamicTable\Support\SchemaIntrospector; use Throwable; use UnitEnum; @@ -30,6 +30,8 @@ class MetadataEngine /** @var array */ protected array $pathMemo = []; + protected SchemaIntrospector $schema; + /** Method names on Eloquent/base classes that must never be probed as relations. */ protected const SKIP_METHODS = [ 'newQuery', 'newModelQuery', 'newQueryWithoutRelationships', 'newCollection', @@ -46,7 +48,10 @@ class MetadataEngine 'getQualifiedKeyName', 'query', 'on', 'onWriteConnection', 'all', 'with', ]; - public function __construct(protected ?string $storeName = null) {} + public function __construct(protected ?string $storeName = null, ?SchemaIntrospector $schema = null) + { + $this->schema = $schema ?? new SchemaIntrospector; + } /** * @param class-string|Model $model @@ -305,7 +310,7 @@ protected function introspect(Model $model): array $columns = []; try { - $columns = Schema::connection($connection)->getColumns($table); + $columns = $this->schema->columns($connection, $table); } catch (Throwable) { $columns = []; } @@ -313,7 +318,7 @@ protected function introspect(Model $model): array $indexed = []; try { - foreach (Schema::connection($connection)->getIndexes($table) as $index) { + foreach ($this->schema->indexes($connection, $table) as $index) { foreach ($index['columns'] as $column) { $indexed[$column] = true; } diff --git a/src/Support/SchemaIntrospector.php b/src/Support/SchemaIntrospector.php new file mode 100644 index 0000000..4d2ba98 --- /dev/null +++ b/src/Support/SchemaIntrospector.php @@ -0,0 +1,347 @@ + ..., 'type_name' => ..., 'type' => ..., 'nullable' => ...]`. + * + * @return array> + */ + public function columns(?string $connection, string $table): array + { + $builder = Schema::connection($connection); + + if (method_exists($builder, 'getColumns')) { + return $builder->getColumns($table); + } + + $database = DB::connection($connection); + $prefixed = $database->getTablePrefix().$table; + + return match ($database->getDriverName()) { + 'sqlite' => $this->sqliteColumns($database, $prefixed), + 'mysql', 'mariadb' => $this->mysqlColumns($database, $prefixed), + 'pgsql' => $this->postgresColumns($database, $prefixed), + 'sqlsrv' => $this->sqlServerColumns($database, $prefixed), + default => [], + }; + } + + /** + * Indexes of a table, as `['name' => ..., 'columns' => [...], 'unique' => ..., 'primary' => ...]`. + * + * @return array> + */ + public function indexes(?string $connection, string $table): array + { + $builder = Schema::connection($connection); + + if (method_exists($builder, 'getIndexes')) { + return $builder->getIndexes($table); + } + + $database = DB::connection($connection); + $prefixed = $database->getTablePrefix().$table; + + return match ($database->getDriverName()) { + 'sqlite' => $this->sqliteIndexes($database, $prefixed), + 'mysql', 'mariadb' => $this->mysqlIndexes($database, $prefixed), + 'pgsql' => $this->postgresIndexes($database, $prefixed), + 'sqlsrv' => $this->sqlServerIndexes($database, $prefixed), + default => [], + }; + } + + /* ------------------------------------------------------------------ */ + /* SQLite */ + /* ------------------------------------------------------------------ */ + + /** @return array> */ + protected function sqliteColumns(Connection $connection, string $table): array + { + $columns = []; + + foreach ($connection->select('pragma table_info('.$this->quote($table).')') as $row) { + $columns[] = $this->column( + (string) $row->name, + (string) $row->type, + ! $row->notnull, + ); + } + + return $columns; + } + + /** @return array> */ + protected function sqliteIndexes(Connection $connection, string $table): array + { + $indexes = []; + + // An `integer primary key` column is the rowid alias and never appears in + // index_list, so the primary key is read from the column list instead. + $primary = []; + + foreach ($connection->select('pragma table_info('.$this->quote($table).')') as $row) { + if ((int) $row->pk > 0) { + $primary[(int) $row->pk] = (string) $row->name; + } + } + + if ($primary !== []) { + ksort($primary); + + $indexes[] = [ + 'name' => 'primary', + 'columns' => array_values($primary), + 'type' => null, + 'unique' => true, + 'primary' => true, + ]; + } + + foreach ($connection->select('pragma index_list('.$this->quote($table).')') as $index) { + $name = (string) $index->name; + $columns = []; + + foreach ($connection->select('pragma index_info('.$this->quote($name).')') as $column) { + if ($column->name !== null) { + $columns[] = (string) $column->name; + } + } + + $indexes[] = [ + 'name' => $name, + 'columns' => $columns, + 'type' => null, + 'unique' => (bool) $index->unique, + 'primary' => ($index->origin ?? null) === 'pk', + ]; + } + + return $indexes; + } + + /* ------------------------------------------------------------------ */ + /* MySQL / MariaDB */ + /* ------------------------------------------------------------------ */ + + /** @return array> */ + protected function mysqlColumns(Connection $connection, string $table): array + { + $rows = $connection->select( + 'select column_name as name, column_type as full_type, data_type as type_name, ' + .'is_nullable as nullable from information_schema.columns ' + .'where table_schema = database() and table_name = ? order by ordinal_position', + [$table], + ); + + $columns = []; + + foreach ($rows as $row) { + $columns[] = $this->column( + (string) $row->name, + (string) $row->full_type, + $this->nullable($row->nullable), + (string) $row->type_name, + ); + } + + return $columns; + } + + /** @return array> */ + protected function mysqlIndexes(Connection $connection, string $table): array + { + $rows = $connection->select( + 'select index_name as name, column_name as column_name, non_unique as non_unique ' + .'from information_schema.statistics ' + .'where table_schema = database() and table_name = ? order by index_name, seq_in_index', + [$table], + ); + + return $this->group($rows, fn (object $row): bool => ! (bool) $row->non_unique); + } + + /* ------------------------------------------------------------------ */ + /* PostgreSQL */ + /* ------------------------------------------------------------------ */ + + /** @return array> */ + protected function postgresColumns(Connection $connection, string $table): array + { + $rows = $connection->select( + 'select column_name as name, udt_name as type_name, is_nullable as nullable, ' + .'character_maximum_length as length from information_schema.columns ' + .'where table_schema = current_schema() and table_name = ? order by ordinal_position', + [$table], + ); + + $columns = []; + + foreach ($rows as $row) { + $type = (string) $row->type_name; + + $columns[] = $this->column( + (string) $row->name, + $row->length !== null ? $type.'('.(int) $row->length.')' : $type, + $this->nullable($row->nullable), + $type, + ); + } + + return $columns; + } + + /** @return array> */ + protected function postgresIndexes(Connection $connection, string $table): array + { + $rows = $connection->select( + 'select ic.relname as name, a.attname as column_name, ix.indisunique as is_unique, ' + .'ix.indisprimary as is_primary from pg_index ix ' + .'join pg_class c on c.oid = ix.indrelid ' + .'join pg_class ic on ic.oid = ix.indexrelid ' + .'join pg_attribute a on a.attrelid = c.oid and a.attnum = any(ix.indkey) ' + .'where c.relname = ? and c.relnamespace = (select oid from pg_namespace where nspname = current_schema())', + [$table], + ); + + return $this->group( + $rows, + fn (object $row): bool => (bool) $row->is_unique, + fn (object $row): bool => (bool) $row->is_primary, + ); + } + + /* ------------------------------------------------------------------ */ + /* SQL Server */ + /* ------------------------------------------------------------------ */ + + /** @return array> */ + protected function sqlServerColumns(Connection $connection, string $table): array + { + $rows = $connection->select( + 'select column_name as name, data_type as type_name, is_nullable as nullable, ' + .'character_maximum_length as length from information_schema.columns ' + .'where table_name = ? order by ordinal_position', + [$table], + ); + + $columns = []; + + foreach ($rows as $row) { + $type = (string) $row->type_name; + $length = $row->length !== null && (int) $row->length > 0 ? (int) $row->length : null; + + $columns[] = $this->column( + (string) $row->name, + $length !== null ? $type.'('.$length.')' : $type, + $this->nullable($row->nullable), + $type, + ); + } + + return $columns; + } + + /** @return array> */ + protected function sqlServerIndexes(Connection $connection, string $table): array + { + $rows = $connection->select( + 'select i.name as name, col.name as column_name, i.is_unique as is_unique, ' + .'i.is_primary_key as is_primary from sys.indexes i ' + .'join sys.index_columns ic on ic.object_id = i.object_id and ic.index_id = i.index_id ' + .'join sys.columns col on col.object_id = ic.object_id and col.column_id = ic.column_id ' + .'where i.object_id = object_id(?) order by i.name, ic.key_ordinal', + [$table], + ); + + return $this->group( + $rows, + fn (object $row): bool => (bool) $row->is_unique, + fn (object $row): bool => (bool) $row->is_primary, + ); + } + + /* ------------------------------------------------------------------ */ + /* Shared */ + /* ------------------------------------------------------------------ */ + + /** @return array */ + protected function column(string $name, string $type, bool $nullable, ?string $typeName = null): array + { + $type = strtolower(trim($type)); + + return [ + 'name' => $name, + 'type_name' => strtolower(trim($typeName ?? explode('(', $type)[0])), + 'type' => $type, + 'nullable' => $nullable, + ]; + } + + /** + * Collapse one row per index column into one entry per index. + * + * @param array $rows + * @param (callable(object): bool)|null $unique + * @param (callable(object): bool)|null $primary + * @return array> + */ + protected function group(array $rows, ?callable $unique = null, ?callable $primary = null): array + { + $indexes = []; + + foreach ($rows as $row) { + $name = (string) $row->name; + + $indexes[$name] ??= [ + 'name' => $name, + 'columns' => [], + 'type' => null, + 'unique' => $unique !== null && $unique($row), + 'primary' => $primary !== null + ? $primary($row) + : strtolower($name) === 'primary', + ]; + + if ($row->column_name !== null) { + $indexes[$name]['columns'][] = (string) $row->column_name; + } + } + + return array_values($indexes); + } + + /** Information-schema nullability is the string `YES`/`NO`; SQL Server reports a bit. */ + protected function nullable(mixed $value): bool + { + if (is_string($value)) { + return strtoupper($value) === 'YES'; + } + + return (bool) $value; + } + + /** Quote a table or index name for a PRAGMA, which takes no bindings. */ + protected function quote(string $name): string + { + return "'".str_replace("'", "''", $name)."'"; + } +} diff --git a/tests/Unit/SchemaIntrospectorTest.php b/tests/Unit/SchemaIntrospectorTest.php new file mode 100644 index 0000000..1d86d91 --- /dev/null +++ b/tests/Unit/SchemaIntrospectorTest.php @@ -0,0 +1,57 @@ +> */ + public function legacyColumns(string $table): array + { + return $this->sqliteColumns(DB::connection(), $table); + } + + /** @return array> */ + public function legacyIndexes(string $table): array + { + return $this->sqliteIndexes(DB::connection(), $table); + } +} + +it('describes columns without the native schema methods', function (): void { + $columns = collect((new LegacySchemaIntrospector)->legacyColumns('users')) + ->keyBy('name'); + + expect($columns)->toHaveKey('id') + ->and($columns['id']['type_name'])->toBe('integer') + ->and($columns['id']['nullable'])->toBeFalse() + ->and($columns['name']['type_name'])->toBe('varchar') + ->and($columns['name']['type'])->toBe('varchar') + ->and($columns['name']['nullable'])->toBeFalse(); +}); + +it('matches the native column list on this Laravel version', function (): void { + if (! method_exists(Schema::connection(null), 'getColumns')) { + $this->markTestSkipped('Laravel 10 has no native getColumns() to compare against.'); + } + + $native = collect(Schema::getColumns('users'))->pluck('type_name', 'name')->all(); + $legacy = collect((new LegacySchemaIntrospector)->legacyColumns('users'))->pluck('type_name', 'name')->all(); + + expect($legacy)->toBe($native); +}); + +it('reports indexed columns without the native schema methods', function (): void { + $columns = collect((new LegacySchemaIntrospector)->legacyIndexes('users')) + ->flatMap(fn (array $index): array => $index['columns']) + ->unique() + ->values(); + + expect($columns)->toContain('id')->toContain('email'); +});