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'); +});