1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-13 01:44:04 +00:00

[4.x] TableRLSManager refactor, comment constraints (#1354)

* Add option to provide constraint information in column comment

* Fix code style (php-cs-fixer)

* Correct comment functionality, add comment constraint exception

* Simplify and clarify comment-related TableRLSManager code

* Make path skipping logic more explicit

* Correct terminology, add test for throwing exceptions

* Fix code style (php-cs-fixer)

* Improve comments

* Refactor TableRLSManagerr (dynamic programming, deal with recursive relationships, determine shortest paths while generating the paths)

* Fix code style (php-cs-fixer)

* Improve TableRLSManager comments

* Test uncovered edge cases

* Improve code for determining the shortest path

* Improve readability

* Fix code style (php-cs-fixer)

* Update the tree terminology

* Use consistent shortest path terminology

* Improve comment

* Improve method name

* Simplify and clarify core shortest path generation test

* Clarify and simplify tests, add comments

* Delete excessive test

* Test data separation with comment constraints

* Use tenant id instead of getTenantKey()

* Make higher-level code clearer, improve comments

* Improve comments, delete excessive methods, make methods more concise, position helper methods more appropriately

* Fix code style (php-cs-fixer)

* Add a "single source of truth" for path array format, make lower-level code more concise, improve comments

* Fix code style (php-cs-fixer)

* Correct terminology and comments in TableRLSManager

* Correct terminology in table manager test file

* Improve comments and method name

* Fix typo

* bump php memory limit when running tests

* Delete findShortestPath, merge the code into shortestPathToTenantsTabke

* Minor shortestPathToTenantsTable improvement

* Improve docblocks,as discussed

* Move RLSCommentConstraintException to src/RLS/Exceptions

* Fully cover shouldSkipPathLeadingThrough in tests

* test improvements

* tests: add comment to clarify the chosen path

* formatting

* Fix typo

* Use `===` instead of `Str::is()`

* Refactor constraint formatting in TableRLSManager

* Fix code style (php-cs-fixer)

* Update key names of the formatted constraints

* Rename shouldSkipPathLeadingThrough() to shouldSkipPathLeadingThroughConstraint()

* misc improvements

* code improvements

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Samuel Štancl <samuel@archte.ch>
This commit is contained in:
lukinovec 2025-07-03 21:12:04 +02:00 committed by GitHub
parent d1f12f594d
commit 4ead17a56b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 777 additions and 355 deletions

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\RLS\Exceptions;
use Exception;
class RLSCommentConstraintException extends Exception
{
public function __construct(string|null $message = null)
{
parent::__construct($message ?? 'Invalid comment constraint.');
}
}

View file

@ -5,22 +5,90 @@ declare(strict_types=1);
namespace Stancl\Tenancy\RLS\PolicyManagers;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Str;
use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException;
use Stancl\Tenancy\RLS\Exceptions\RLSCommentConstraintException;
// todo@samuel logical + structural refactor. the tree generation could use some dynamic programming optimizations
/**
* Generates queries for creating RLS policies
* for tables related to the tenants table.
*
* Usage:
* // Generate queries for creating RLS policies.
* // The queries will be returned in this format:
* // [
* // <<<SQL
* // CREATE POLICY authors_rls_policy ON authors USING (
* // tenant_id::text = current_setting('my.current_tenant')
* // );
* // SQL,
* // <<<SQL
* // CREATE POLICY posts_rls_policy ON posts USING (
* // author_id IN (
* // SELECT id
* // FROM authors
* // WHERE tenant_id::text = current_setting('my.current_tenant')
* // )
* // );
* // SQL,
* // ]
* // This is used In the CreateUserWithRLSPolicies command.
* // Calls shortestPaths() internally to generate paths, then generates queries for each path.
* $queries = app(TableRLSManager::class)->generateQueries();
*
* // Generate the shortest path from table X to the tenants table.
* // Calls shortestPathToTenantsTable() recursively.
* // The paths will be returned in this format:
* // [
* // 'foo_table' => [...$stepsLeadingToTenantsTable],
* // 'bar_table' => [
* // [
* // 'localColumn' => 'post_id',
* // 'foreignTable' => 'posts',
* // 'foreignColumn' => 'id'
* // ],
* // [
* // 'localColumn' => 'tenant_id',
* // 'foreignTable' => 'tenants',
* // 'foreignColumn' => 'id'
* // ],
* // ],
* // This is used in the CreateUserWithRLSPolicies command.
* $shortestPath = app(TableRLSManager::class)->shortestPaths();
*
* generateQueries() and shortestPaths() methods are the only public methods of this class.
* The rest of the methods are protected, and only used internally.
* To see how they're structured and how they work, you can check their annotations.
*/
class TableRLSManager implements RLSPolicyManager
{
/**
* When true, all valid constraints are considered while generating paths for RLS policies,
* unless explicitly marked with a 'no-rls' comment.
*
* When false, only columns explicitly marked with 'rls' or 'rls table.column' comments are considered.
*/
public static bool $scopeByDefault = true;
public function __construct(
protected DatabaseManager $database
) {}
public function generateQueries(array $trees = []): array
/**
* Generate queries that will be executed by the tenants:rls command
* for creating RLS policies for all tables related to the tenants table
* or for a passed array of paths.
*
* The passed paths should be formatted like this:
* [
* 'table_name' => [...$stepsLeadingToTenantsTable]
* ]
*/
public function generateQueries(array $paths = []): array
{
$queries = [];
foreach ($trees ?: $this->shortestPaths() as $table => $path) {
foreach ($paths ?: $this->shortestPaths() as $table => $path) {
$queries[$table] = $this->generateQuery($table, $path);
}
@ -28,185 +96,415 @@ class TableRLSManager implements RLSPolicyManager
}
/**
* Reduce trees to shortest paths (structured like ['table_foo' => $shortestPathForFoo, 'table_bar' => $shortestPathForBar]).
* Generate shortest paths from each table to the tenants table,
* structured like ['table_foo' => $shortestPathFromFoo, 'table_bar' => $shortestPathFromBar].
*
* For example:
*
* 'posts' => [
* [
* 'foreignKey' => 'tenant_id',
* 'localColumn' => 'tenant_id',
* 'foreignTable' => 'tenants',
* 'foreignId' => 'id'
* 'foreignColumn' => 'id'
* ],
* ],
* 'comments' => [
* [
* 'foreignKey' => 'post_id',
* 'localColumn' => 'post_id',
* 'foreignTable' => 'posts',
* 'foreignId' => 'id'
* 'foreignColumn' => 'id'
* ],
* [
* 'foreignKey' => 'tenant_id',
* 'localColumn' => 'tenant_id',
* 'foreignTable' => 'tenants',
* 'foreignId' => 'id'
* 'foreignColumn' => 'id'
* ],
* ],
*
* @throws RecursiveRelationshipException When tables have recursive relationships and no other valid paths
* @throws RLSCommentConstraintException When comment constraints are malformed
*/
public function shortestPaths(array $trees = []): array
public function shortestPaths(): array
{
$reducedTrees = [];
$shortestPaths = [];
foreach ($trees ?: $this->generateTrees() as $table => $tree) {
$reducedTrees[$table] = $this->findShortestPath($this->filterNonNullablePaths($tree) ?: $tree);
foreach ($this->getTableNames() as $tableName) {
// Generate the shortest path from table named $tableName to the tenants table
$shortestPath = $this->shortestPathToTenantsTable($tableName);
if ($this->isValidPath($shortestPath)) {
// Format path steps to a more readable format (keep only the needed data)
$shortestPaths[$tableName] = array_map(fn (array $step) => [
'localColumn' => $step['localColumn'],
'foreignTable' => $step['foreignTable'],
'foreignColumn' => $step['foreignColumn'],
], $shortestPath['steps']);
}
// No valid path found. The shortest path either
// doesn't lead to the tenants table (ignore),
// or leads through a recursive relationship (throw an exception).
if ($shortestPath['recursive_relationship']) {
throw new RecursiveRelationshipException(
"Table '{$tableName}' has recursive relationships with no other valid paths to the tenants table."
);
}
}
return $reducedTrees;
return $shortestPaths;
}
/**
* Generate trees of paths that lead to the tenants table
* for the foreign keys of all tables only the paths that lead to the tenants table are included.
* Create a path array with the given parameters.
* This method serves as a 'single source of truth' for the path array structure.
*
* Also unset the 'comment' key from the retrieved path steps.
* The 'steps' key contains the path steps returned by shortestPaths().
* The 'dead_end' and 'recursive_relationship' keys are just internal metadata.
*
* @param bool $deadEnd Whether the path is a dead end (no valid constraints leading to tenants table)
* @param bool $recursive Whether the path has recursive relationships
* @param array $steps Steps to the tenants table, each step being a formatted constraint
*/
public function generateTrees(): array
protected function buildPath(bool $deadEnd = false, bool $recursive = false, array $steps = []): array
{
$trees = [];
$builder = $this->database->getSchemaBuilder();
// We loop through each table in the database
foreach ($builder->getTableListing(schema: $this->database->getConfig('search_path')) as $table) {
// E.g. "public.table_name" -> "table_name"
$table = str($table)->afterLast('.')->toString();
// For each table, we get a list of all foreign key columns
$foreignKeys = collect($builder->getForeignKeys($table))->map(function ($foreign) use ($table) {
return $this->formatForeignKey($foreign, $table);
});
// We loop through each foreign key column and find
// all possible paths that lead to the tenants table
foreach ($foreignKeys as $foreign) {
$paths = [];
$this->generatePaths($table, $foreign, $paths);
foreach ($paths as &$path) {
foreach ($path as &$step) {
unset($step['comment']);
}
}
if (count($paths)) {
$trees[$table][$foreign['foreignKey']] = $paths;
}
}
}
return $trees;
}
protected function generatePaths(string $table, array $foreign, array &$paths, array $currentPath = []): void
{
// If the foreign key has a comment of 'no-rls', we skip it
// Also skip the foreign key if implicit scoping is off and the foreign key has no comment
if ($foreign['comment'] === 'no-rls' || (! static::$scopeByDefault && $foreign['comment'] === null)) {
return;
}
if (in_array($foreign['foreignTable'], array_column($currentPath, 'foreignTable'))) {
throw new RecursiveRelationshipException;
}
$currentPath[] = $foreign;
if ($foreign['foreignTable'] === tenancy()->model()->getTable()) {
$paths[] = $currentPath;
} else {
// If not, recursively generate paths for the foreign table
foreach ($this->database->getSchemaBuilder()->getForeignKeys($foreign['foreignTable']) as $nextConstraint) {
$this->generatePaths($table, $this->formatForeignKey($nextConstraint, $foreign['foreignTable']), $paths, $currentPath);
}
}
}
/** Get tree's non-nullable paths. */
protected function filterNonNullablePaths(array $tree): array
{
$nonNullablePaths = [];
foreach ($tree as $foreignKey => $paths) {
foreach ($paths as $path) {
$pathIsNullable = false;
foreach ($path as $step) {
if ($step['nullable']) {
$pathIsNullable = true;
break;
}
}
if (! $pathIsNullable) {
$nonNullablePaths[$foreignKey][] = $path;
}
}
}
return $nonNullablePaths;
}
/** Find the shortest path in a tree and unset the 'nullable' key from the path steps. */
protected function findShortestPath(array $tree): array
{
$shortestPath = [];
foreach ($tree as $pathsForForeignKey) {
foreach ($pathsForForeignKey as $path) {
if (empty($shortestPath) || count($shortestPath) > count($path)) {
$shortestPath = $path;
foreach ($shortestPath as &$step) {
unset($step['nullable']);
}
}
}
}
return $shortestPath;
return [
'dead_end' => $deadEnd,
'recursive_relationship' => $recursive,
'steps' => $steps,
];
}
/**
* Formats the foreign key array retrieved by Postgres to a more readable format.
* Formats the retrieved constraint to a more readable format.
*
* Also provides information about whether the foreign key is nullable,
* and the foreign key column comment. These additional details are removed
* from the foreign keys/path steps before returning the final shortest paths.
* Also provides internal metadata about
* - the constraint's nullability (the 'nullable' key),
* - the constraint's comment
*
* The 'comment' key gets deleted while generating the full trees (in generateTrees()),
* and the 'nullable' key gets deleted while generating the shortest paths (in findShortestPath()).
* These internal details are then omitted
* from the constraints (or the "path steps")
* before returning the shortest paths in shortestPath().
*
* [
* 'foreignKey' => 'tenant_id',
* 'localColumn' => 'tenant_id',
* 'foreignTable' => 'tenants',
* 'foreignId' => 'id',
* 'comment' => 'no-rls', // Foreign key comment used to explicitly enable/disable RLS
* 'nullable' => false, // Whether the foreign key is nullable
* 'foreignColumn' => 'id',
* 'comment' => 'no-rls', // Used to explicitly enable/disable RLS or to create a comment constraint (internal metadata)
* 'nullable' => false, // Used to determine if the constraint is nullable (internal metadata)
* ].
*/
protected function formatForeignKey(array $foreignKey, string $table): array
protected function formatForeignKey(array $constraint, string $table): array
{
// $foreignKey is one of the foreign keys retrieved by $this->database->getSchemaBuilder()->getForeignKeys($table)
assert(count($constraint['columns']) === 1);
$localColumn = $constraint['columns'][0];
$comment = collect($this->database->getSchemaBuilder()->getColumns($table))
->filter(fn ($column) => $column['name'] === $localColumn)
->first()['comment'] ?? null;
$columnIsNullable = $this->database->selectOne(
'SELECT is_nullable FROM information_schema.columns WHERE table_name = ? AND column_name = ?',
[$table, $localColumn]
)->is_nullable === 'YES';
assert(count($constraint['foreign_columns']) === 1);
return $this->formatConstraint(
localColumn: $localColumn,
foreignTable: $constraint['foreign_table'],
foreignColumn: $constraint['foreign_columns'][0],
comment: $comment,
nullable: $columnIsNullable
);
}
/** Single source of truth for our constraint format. */
protected function formatConstraint(
string $localColumn,
string $foreignTable,
string $foreignColumn,
string|null $comment,
bool $nullable
): array {
return [
'foreignKey' => $foreignKeyName = $foreignKey['columns'][0],
'foreignTable' => $foreignKey['foreign_table'],
'foreignId' => $foreignKey['foreign_columns'][0],
// Deleted in generateTrees()
'comment' => $this->getComment($table, $foreignKeyName),
// Deleted in shortestPaths()
'nullable' => $this->database->selectOne("SELECT is_nullable FROM information_schema.columns WHERE table_name = '{$table}' AND column_name = '{$foreignKeyName}'")->is_nullable === 'YES',
'localColumn' => $localColumn,
'foreignTable' => $foreignTable,
'foreignColumn' => $foreignColumn,
// Internal metadata omitted in shortestPaths()
'comment' => $comment,
'nullable' => $nullable,
];
}
/**
* Recursively traverse a table's constraints to find
* the shortest path to the tenants table.
*
* The shortest paths are cached in $cachedPaths to avoid
* generating them for already visited tables repeatedly.
*
* @param string $table The table to find a path from
* @param array &$cachedPaths Reference to array where discovered shortest paths are cached (including dead ends)
* @param array $visitedTables Already visited tables (used for detecting recursive relationships)
* @return array Paths with 'steps' (arrays of formatted constraints), 'dead_end' flag (bool), and 'recursive_relationship' flag (bool).
*/
protected function shortestPathToTenantsTable(
string $table,
array &$cachedPaths = [],
array $visitedTables = []
): array {
// Return the shortest path for this table if it was already found and cached
if (isset($cachedPaths[$table])) {
return $cachedPaths[$table];
}
// Reached tenants table (last step)
if ($table === tenancy()->model()->getTable()) {
// This pretty much just means we set $cachedPaths['tenants'] to an
// empty path. The significance of an empty path is that this class
// considers it to mean "you are at the tenants table".
$cachedPaths[$table] = $this->buildPath();
return $cachedPaths[$table];
}
$constraints = $this->getConstraints($table);
if (empty($constraints)) {
// Dead end
$cachedPaths[$table] = $this->buildPath(deadEnd: true);
return $cachedPaths[$table];
}
/**
* Find the optimal path from a table to the tenants table.
*
* Gather table's constraints (both foreign key constraints and comment constraints)
* and recursively find shortest paths through each constraint (non-nullable paths are preferred for reliability).
*
* Handle recursive relationships by skipping paths that would create loops.
* If there's no valid path in the end, and the table has recursive relationships,
* an appropriate exception is thrown.
*
* At the end, it returns the shortest non-nullable path if available,
* fall back to the overall shortest path.
*/
$visitedTables = [...$visitedTables, $table];
$shortestPath = [];
$hasRecursiveRelationships = false;
$hasValidPaths = false;
foreach ($constraints as $constraint) {
$foreignTable = $constraint['foreignTable'];
// Skip constraints that would create loops
if (in_array($foreignTable, $visitedTables)) {
$hasRecursiveRelationships = true;
continue;
}
// Recursive call
$pathThroughConstraint = $this->shortestPathToTenantsTable(
$foreignTable,
$cachedPaths,
$visitedTables
);
if ($pathThroughConstraint['recursive_relationship']) {
$hasRecursiveRelationships = true;
continue;
}
// Skip dead ends
if ($pathThroughConstraint['dead_end']) {
continue;
}
$hasValidPaths = true;
$path = $this->buildPath(steps: array_merge([$constraint], $pathThroughConstraint['steps']));
if ($this->isPathPreferable($path, $shortestPath)) {
$shortestPath = $path;
}
}
// Handle tables with only recursive relationships
if ($hasRecursiveRelationships && ! $hasValidPaths) {
// Don't cache paths that cause recursion - return right away.
// This allows tables with recursive relationships to be processed again.
// Example:
// - posts table has highlighted_comment_id that leads to the comments table
// - comments table has recursive_post_id that leads to the posts table (recursive relationship),
// - comments table also has tenant_id which leads to the tenants table (a valid path).
// If the recursive path got cached first, the path leading directly through tenants would never be found.
return $this->buildPath(recursive: true);
}
$cachedPaths[$table] = $shortestPath ?: $this->buildPath(deadEnd: true);
return $cachedPaths[$table];
}
/**
* Get all valid relationship constraints for a table. The constraints are also formatted.
* Combines both standard foreign key constraints and comment constraints.
*
* The schema builder retrieves foreign keys in the following format:
* [
* 'name' => 'posts_tenant_id_foreign',
* 'columns' => ['tenant_id'],
* 'foreign_table' => 'tenants',
* 'foreign_columns' => ['id'],
* ...
* ]
*
* We format that into a more readable format using formatForeignKey(),
* and that method uses formatConstraint(), which serves as a single source of truth
* for our constraint formatting. A formatted constraint looks like this:
* [
* 'localColumn' => 'tenant_id',
* 'foreignTable' => 'tenants',
* 'foreignColumn' => 'id',
* 'comment' => 'no-rls',
* 'nullable' => false
* ]
*
* The comment constraints are retrieved using getFormattedCommentConstraints().
* These constraints are formatted in the method itself.
*/
protected function getConstraints(string $table): array
{
$formattedConstraints = array_merge(
array_map(
fn ($schemaStructure) => $this->formatForeignKey($schemaStructure, $table),
$this->database->getSchemaBuilder()->getForeignKeys($table)
),
$this->getFormattedCommentConstraints($table)
);
$validConstraints = [];
foreach ($formattedConstraints as $constraint) {
if (! $this->shouldSkipPathLeadingThroughConstraint($constraint)) {
$validConstraints[] = $constraint;
}
}
return $validConstraints;
}
/**
* Determine if a path leading through the passed constraint
* should be excluded from choosing the shortest path
* based on the constraint's comment.
*
* If $scopeByDefault is true, only skip paths leading through constraints flagged with the 'no-rls' comment.
* If $scopeByDefault is false, skip paths leading through any constraint, unless the key has explicit 'rls' or 'rls table.column' comments.
*
* @param array $constraint Formatted constraint
*/
protected function shouldSkipPathLeadingThroughConstraint(array $constraint): bool
{
$comment = $constraint['comment'] ?? null;
// Always skip constraints with the 'no-rls' comment
if ($comment === 'no-rls') {
return true;
}
if (static::$scopeByDefault) {
return false;
}
// When $scopeByDefault is false, skip every constraint
// with a comment that doesn't start with 'rls'.
if (! is_string($comment)) {
return true;
}
// Explicit scoping
if ($comment === 'rls') {
return false;
}
// Comment constraint
if (Str::startsWith($comment, 'rls ')) {
return false;
}
return true;
}
/**
* Retrieve a table's comment constraints.
*
* Comment constraints are columns with comments
* structured like "rls <foreign_table>.<foreign_column>".
*
* Returns an array of formatted comment constraints (check formatConstraint() to see the format).
*/
protected function getFormattedCommentConstraints(string $tableName): array
{
$commentConstraints = array_filter($this->database->getSchemaBuilder()->getColumns($tableName), function ($column) {
return (isset($column['comment']) && is_string($column['comment']))
&& Str::startsWith($column['comment'], 'rls ');
});
// Validate and format the comment constraints
$commentConstraints = array_map(
fn ($commentConstraint) => $this->parseCommentConstraint($commentConstraint, $tableName),
$commentConstraints
);
return $commentConstraints;
}
/**
* Parse and validate a comment constraint.
*
* This method validates that the table and column referenced
* in the comment exist, formats and returns the constraint.
*
* @throws RLSCommentConstraintException When comment format is invalid or references don't exist
*/
protected function parseCommentConstraint(array $commentConstraint, string $tableName): array
{
$comment = $commentConstraint['comment'];
$columnName = $commentConstraint['name'];
$builder = $this->database->getSchemaBuilder();
$constraint = explode('.', Str::after($comment, 'rls '));
// Validate comment constraint format
if (count($constraint) !== 2 || empty($constraint[0]) || empty($constraint[1])) {
throw new RLSCommentConstraintException("Malformed comment constraint on {$tableName}.{$columnName}: '{$comment}'");
}
$foreignTable = $constraint[0];
$foreignColumn = $constraint[1];
// Validate table existence
if (! $builder->hasTable($foreignTable)) {
throw new RLSCommentConstraintException("Comment constraint on {$tableName}.{$columnName} references non-existent table '{$foreignTable}'");
}
// Validate column existence
if (! $builder->hasColumn($foreignTable, $foreignColumn)) {
throw new RLSCommentConstraintException("Comment constraint on {$tableName}.{$columnName} references non-existent column '{$foreignTable}.{$foreignColumn}'");
}
// Return the formatted constraint
return $this->formatConstraint(
localColumn: $commentConstraint['name'],
foreignTable: $foreignTable,
foreignColumn: $foreignColumn,
comment: $commentConstraint['comment'],
nullable: $commentConstraint['nullable']
);
}
/** Generates a query that creates a row-level security policy for the passed table. */
protected function generateQuery(string $table, array $path): string
{
@ -215,9 +513,9 @@ class TableRLSManager implements RLSPolicyManager
$sessionTenantKey = config('tenancy.rls.session_variable_name');
foreach ($path as $index => $relation) {
$column = $relation['foreignKey'];
$column = $relation['localColumn'];
$table = $relation['foreignTable'];
$foreignKey = $relation['foreignId'];
$foreignKey = $relation['foreignColumn'];
$indentation = str_repeat(' ', ($index + 1) * 4);
@ -250,12 +548,65 @@ class TableRLSManager implements RLSPolicyManager
return $query;
}
protected function getComment(string $tableName, string $columnName): string|null
/** Returns unprefixed table names. */
protected function getTableNames(): array
{
$column = collect($this->database->getSchemaBuilder()->getColumns($tableName))
->filter(fn ($column) => $column['name'] === $columnName)
->first();
$builder = $this->database->getSchemaBuilder();
$tables = [];
return $column['comment'] ?? null;
foreach ($builder->getTableListing(schema: $this->database->getConfig('search_path')) as $table) {
// E.g. "public.table_name" -> "table_name"
$tables[] = str($table)->afterLast('.')->toString();
}
return $tables;
}
/**
* Check if discovered path is valid for RLS policy generation.
*
* A valid path:
* - leads to tenants table (isn't dead end)
* - has at least one step (the tenants table itself will have no steps)
*/
protected function isValidPath(array $path): bool
{
return ! $path['dead_end'] && ! empty($path['steps']);
}
/**
* Determine if the passed path is preferred to the current shortest path.
*
* Non-nullable paths are preferred to nullable paths.
* From paths of the same nullability, the shorter will be preferred.
*/
protected function isPathPreferable(array $path, array $shortestPath): bool
{
if (! $shortestPath) {
return true;
}
$pathIsNullable = $this->isPathNullable($path['steps']);
$shortestPathIsNullable = $this->isPathNullable($shortestPath['steps']);
// Prefer non-nullable
if ($pathIsNullable !== $shortestPathIsNullable) {
return ! $pathIsNullable;
}
// Prefer shorter
return count($path['steps']) < count($shortestPath['steps']);
}
/** Determine if any step in the path is nullable. */
protected function isPathNullable(array $path): bool
{
foreach ($path as $step) {
if ($step['nullable']) {
return true;
}
}
return false;
}
}