1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-02-05 09:54:05 +00:00

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

This commit is contained in:
lukinovec 2025-06-09 15:38:10 +02:00
parent a8941c3373
commit e7d406b3df

View file

@ -94,7 +94,7 @@ class TableRLSManager implements RLSPolicyManager
// No valid path found. The shortest path either // No valid path found. The shortest path either
// doesn't lead to the tenants table (ignore), // doesn't lead to the tenants table (ignore),
// or leads through a recursive relationship (throw an exception). // or leads through a recursive relationship (throw an exception).
if ($shortestPath['recursion']) { if ($shortestPath['recursive_relationship']) {
throw new RecursiveRelationshipException( throw new RecursiveRelationshipException(
"Table '{$tableName}' has recursive relationships with no valid paths to the tenants table." "Table '{$tableName}' has recursive relationships with no valid paths to the tenants table."
); );
@ -109,27 +109,28 @@ class TableRLSManager implements RLSPolicyManager
* the shortest path to the tenants table. * the shortest path to the tenants table.
* *
* The shortest paths are cached in $cachedPaths to avoid * The shortest paths are cached in $cachedPaths to avoid
* recalculating them for tables that have already been processed. * generating them for already visited tables repeatedly.
* *
* @param string $table The table to find a path from * @param string $table The table to find a path from
* @param array &$cachedPaths Reference to array for caching discovered paths * @param array &$cachedPaths Reference to array where discovered shortest paths are cached (including dead ends)
* @param array $visitedTables Tables that were already visited (used for detecting recursion) * @param array $visitedTables Already visited tables (used for detecting recursive relationships)
* @return array Path with 'steps' (array of formatted foreign keys), 'dead_end' flag (bool), and 'recursion' flag (bool). * @return array Paths with 'steps' (arrays of formatted foreign keys), 'dead_end' flag (bool), and 'recursive_relationship' flag (bool).
*/ */
protected function shortestPathToTenantsTable( protected function shortestPathToTenantsTable(
string $table, string $table,
array &$cachedPaths, array &$cachedPaths,
array $visitedTables = [] array $visitedTables = []
): array { ): array {
// Return the shortest path for this table if it was already found and cached
if (isset($cachedPaths[$table])) { if (isset($cachedPaths[$table])) {
return $cachedPaths[$table]; return $cachedPaths[$table];
} }
// Reached tenants table // Reached tenants table (last step)
if ($table === tenancy()->model()->getTable()) { if ($table === tenancy()->model()->getTable()) {
$cachedPaths[$table] = [ $cachedPaths[$table] = [
'dead_end' => false, 'dead_end' => false,
'recursion' => false, 'recursive_relationship' => false,
'steps' => [], 'steps' => [],
]; ];
@ -142,7 +143,7 @@ class TableRLSManager implements RLSPolicyManager
// Dead end // Dead end
$cachedPaths[$table] = [ $cachedPaths[$table] = [
'dead_end' => true, 'dead_end' => true,
'recursion' => false, 'recursive_relationship' => false,
'steps' => [], 'steps' => [],
]; ];
@ -153,12 +154,36 @@ class TableRLSManager implements RLSPolicyManager
} }
/** /**
* Based on the foreign key's comment, * Get all valid foreign key relationships for a table.
* determine if a path leading through the passed foreign key * Combines both standard foreign key constraints and comment-based constraints.
* should be excluded from determining the shortest path. */
protected function getForeignKeys(string $table): array
{
$constraints = array_merge(
$this->database->getSchemaBuilder()->getForeignKeys($table),
$this->getCommentConstraints($table)
);
$foreignKeys = [];
foreach ($constraints as $constraint) {
$formatted = $this->formatForeignKey($constraint, $table);
if (! $this->shouldSkipPathLeadingThrough($formatted)) {
$foreignKeys[] = $formatted;
}
}
return $foreignKeys;
}
/**
* Determine if a path leading through the passed foreign key
* should be excluded from choosing the shortest path
* based on the foreign key's comment.
* *
* If static::$scopeByDefault is true, only skip paths explicitly marked with 'no-rls'. * If static::$scopeByDefault is true, only skip paths leading through foreign keys flagged with the 'no-rls' comment.
* If static::$scopeByDefault is false, skip paths unless they have 'rls' or 'rls table.column' comments. * If static::$scopeByDefault is false, skip paths leading through any foreign key, unless the key has explicit 'rls' or 'rls table.column' comments.
* *
* @param array $foreignKey Formatted foreign key (has to have the 'comment' key) * @param array $foreignKey Formatted foreign key (has to have the 'comment' key)
*/ */
@ -166,18 +191,18 @@ class TableRLSManager implements RLSPolicyManager
{ {
$comment = $foreignKey['comment'] ?? null; $comment = $foreignKey['comment'] ?? null;
// Always skip paths explicitly marked with 'no-rls' // Always skip foreign keys with the 'no-rls' comment
if ($comment === 'no-rls') { if ($comment === 'no-rls') {
return true; return true;
} }
// When scopeByDefault is true, include all paths except 'no-rls'
if (static::$scopeByDefault) { if (static::$scopeByDefault) {
return false; return false;
} }
// When scopeByDefault is false, only include paths with RLS comments // When scopeByDefault is false, skip every foreign key
if (! $comment || ! is_string($comment)) { // with a comment that doesn't start with 'rls'.
if (! is_string($comment)) {
return true; return true;
} }
@ -185,14 +210,30 @@ class TableRLSManager implements RLSPolicyManager
} }
/** /**
* Parse and validate a comment-based constraint string. * Retrieve table's comment-based constraints. These are columns with comments
* formatted like "rls <foreign_table>.<foreign_column>".
* *
* Comment constraints allow manually specifying relationships * Returns the constraints as unformatted foreign key arrays, ready to be formatted by formatForeignKey().
* using comments with format "rls table.column". */
protected function getCommentConstraints(string $tableName): array
{
$commentConstraintColumns = array_filter($this->database->getSchemaBuilder()->getColumns($tableName), function ($column) {
return (isset($column['comment']) && is_string($column['comment']))
&& Str::startsWith($column['comment'], 'rls ');
});
return array_map(
fn ($column) => $this->parseCommentConstraint($column['comment'], $tableName, $column['name']),
$commentConstraintColumns
);
}
/**
* Parse and validate a comment constraint.
* *
* This method parses such comments, validates that the referenced table and column exist, * This method validates that the table and column referenced
* and returns the constraint in a format corresponding with standardly retrieved foreign keys, * in the comment exist, and returns the constraint in a format corresponding to the
* ready to be formatted using formatForeignKey(). * standardly retrieved foreign keys (ready to be formatted using formatForeignKey()).
* *
* @throws RLSCommentConstraintException When comment format is invalid or references don't exist * @throws RLSCommentConstraintException When comment format is invalid or references don't exist
*/ */
@ -227,67 +268,47 @@ class TableRLSManager implements RLSPolicyManager
} }
/** /**
* Retrieve table's comment-based constraints. These are columns with comments * Formats the retrieved foreign key to a more readable format.
* formatted like "rls <foreign_table>.<foreign_column>".
* *
* Returns the constraints as unformatted foreign key arrays, ready to be formatted by formatForeignKey(). * Also provides internal metadata about
*/ * - the foreign key's nullability (the 'nullable' key),
protected function getCommentConstraints(string $tableName): array * - the foreign key's comment
{
$commentConstraintColumns = array_filter($this->database->getSchemaBuilder()->getColumns($tableName), function ($column) {
return (isset($column['comment']) && is_string($column['comment']))
&& Str::startsWith($column['comment'], 'rls ');
});
return array_map(
fn ($column) => $this->parseCommentConstraint($column['comment'], $tableName, $column['name']),
$commentConstraintColumns
);
}
/**
* Formats the foreign key retrieved by Postgres or comment-based constraint to a more readable format.
* *
* Also provides information about whether the foreign key is nullable, * These internal details are then omitted
* and the foreign key column comment. These additional details are removed * from the foreign keys (or the "path steps")
* from the foreign keys/path steps before returning the final shortest paths. * before returning the shortest paths in shortestPath().
*
* The 'comment' key gets deleted while generating the full trees (in shortestPaths()),
* and the 'nullable' key gets deleted while generating the shortest paths (in findShortestPath()).
* *
* [ * [
* 'foreignKey' => 'tenant_id', * 'foreignKey' => 'tenant_id',
* 'foreignTable' => 'tenants', * 'foreignTable' => 'tenants',
* 'foreignId' => 'id', * 'foreignId' => 'id',
* 'comment' => 'no-rls', // Foreign key comment used to explicitly enable/disable RLS * 'comment' => 'no-rls', // Used to explicitly enable/disable RLS or to create a comment constraint (internal metadata)
* 'nullable' => false, // Whether the foreign key is nullable * 'nullable' => false, // Used to determine if the foreign key is nullable (internal metadata)
* ]. * ].
*/ */
protected function formatForeignKey(array $foreignKey, string $table): array protected function formatForeignKey(array $foreignKey, string $table): array
{ {
$foreignKeyName = $foreignKey['columns'][0]; $foreignKeyName = $foreignKey['columns'][0];
$comment = collect($this->database->getSchemaBuilder()->getColumns($table))
->filter(fn ($column) => $column['name'] === $foreignKeyName)
->first()['comment'] ?? null;
$columnIsNullable = $this->database->selectOne(
'SELECT is_nullable FROM information_schema.columns WHERE table_name = ? AND column_name = ?',
[$table, $foreignKeyName]
)?->is_nullable === 'YES';
return [ return [
'foreignKey' => $foreignKeyName, 'foreignKey' => $foreignKeyName,
'foreignTable' => $foreignKey['foreign_table'], 'foreignTable' => $foreignKey['foreign_table'],
'foreignId' => $foreignKey['foreign_columns'][0], 'foreignId' => $foreignKey['foreign_columns'][0],
// Internal metadata (deleted in shortestPaths()) // Internal metadata omitted in shortestPaths()
'comment' => $this->getComment($table, $foreignKeyName), 'comment' => $comment,
'nullable' => $this->isColumnNullable($table, $foreignKeyName), 'nullable' => $columnIsNullable,
]; ];
} }
/** Check if a column is nullable. */
protected function isColumnNullable(string $table, string $column): bool
{
$result = $this->database->selectOne(
'SELECT is_nullable FROM information_schema.columns WHERE table_name = ? AND column_name = ?',
[$table, $column]
);
return $result?->is_nullable === 'YES';
}
/** Generates a query that creates a row-level security policy for the passed table. */ /** Generates a query that creates a row-level security policy for the passed table. */
protected function generateQuery(string $table, array $path): string protected function generateQuery(string $table, array $path): string
{ {
@ -331,27 +352,6 @@ class TableRLSManager implements RLSPolicyManager
return $query; return $query;
} }
protected function getComment(string $tableName, string $columnName): string|null
{
$column = collect($this->database->getSchemaBuilder()->getColumns($tableName))
->filter(fn ($column) => $column['name'] === $columnName)
->first();
return $column['comment'] ?? null;
}
/** Returns true 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;
}
/** Returns unprefixed table names. */ /** Returns unprefixed table names. */
protected function getTableNames(): array protected function getTableNames(): array
{ {
@ -378,30 +378,6 @@ class TableRLSManager implements RLSPolicyManager
return ! $path['dead_end'] && ! empty($path['steps']); return ! $path['dead_end'] && ! empty($path['steps']);
} }
/**
* Get all valid foreign key relationships for a table.
* Combines both standard foreign key constraints and comment-based constraints.
*/
protected function getForeignKeys(string $table): array
{
$constraints = array_merge(
$this->database->getSchemaBuilder()->getForeignKeys($table),
$this->getCommentConstraints($table)
);
$foreignKeys = [];
foreach ($constraints as $constraint) {
$formatted = $this->formatForeignKey($constraint, $table);
if (! $this->shouldSkipPathLeadingThrough($formatted)) {
$foreignKeys[] = $formatted;
}
}
return $foreignKeys;
}
/** /**
* Find the optimal path from a table to the tenants table. * Find the optimal path from a table to the tenants table.
* *
@ -421,7 +397,7 @@ class TableRLSManager implements RLSPolicyManager
* @param array $foreignKeys Array of foreign key relationships to explore * @param array $foreignKeys Array of foreign key relationships to explore
* @param array &$cachedPaths Reference to caching array for memoization * @param array &$cachedPaths Reference to caching array for memoization
* @param array $visitedTables Tables already visited in this path (used for detecting recursion) * @param array $visitedTables Tables already visited in this path (used for detecting recursion)
* @return array Path with 'steps' array, 'dead_end' flag, and 'recursion' flag * @return array Path with 'steps' array, 'dead_end' flag, and 'recursive_relationship' flag
*/ */
protected function determineShortestPath( protected function determineShortestPath(
string $table, string $table,
@ -449,7 +425,7 @@ class TableRLSManager implements RLSPolicyManager
$visitedTables $visitedTables
); );
if (isset($foreignPath['recursion']) && $foreignPath['recursion']) { if (isset($foreignPath['recursive_relationship']) && $foreignPath['recursive_relationship']) {
$hasRecursiveRelationships = true; $hasRecursiveRelationships = true;
continue; continue;
} }
@ -459,7 +435,7 @@ class TableRLSManager implements RLSPolicyManager
// Build the full path with the current foreign key as the first step // Build the full path with the current foreign key as the first step
$path = [ $path = [
'dead_end' => false, 'dead_end' => false,
'recursion' => false, 'recursive_relationship' => false,
'steps' => array_merge([$foreign], $foreignPath['steps']), 'steps' => array_merge([$foreign], $foreignPath['steps']),
]; ];
@ -472,7 +448,7 @@ class TableRLSManager implements RLSPolicyManager
if ($hasRecursiveRelationships && ! $hasValidPaths) { if ($hasRecursiveRelationships && ! $hasValidPaths) {
$finalPath = [ $finalPath = [
'dead_end' => false, 'dead_end' => false,
'recursion' => true, 'recursive_relationship' => true,
'steps' => [], 'steps' => [],
]; ];
@ -486,7 +462,7 @@ class TableRLSManager implements RLSPolicyManager
} else { } else {
$finalPath = $shortestPath ?: [ $finalPath = $shortestPath ?: [
'dead_end' => true, 'dead_end' => true,
'recursion' => false, 'recursive_relationship' => false,
'steps' => [], 'steps' => [],
]; ];
} }
@ -519,4 +495,16 @@ class TableRLSManager implements RLSPolicyManager
// Prefer shorter // Prefer shorter
return count($path['steps']) < count($shortestPath['steps']); 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;
}
} }