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

Correct comment functionality, add comment constraint exception

This commit is contained in:
lukinovec 2025-05-21 11:25:09 +02:00
parent a52745704e
commit f65c64c9c7
3 changed files with 83 additions and 110 deletions

View file

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

View file

@ -7,6 +7,7 @@ namespace Stancl\Tenancy\RLS\PolicyManagers;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Str;
use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException;
use Stancl\Tenancy\Exceptions\RLSCommentConstraintException;
// todo@samuel logical + structural refactor. the tree generation could use some dynamic programming optimizations
class TableRLSManager implements RLSPolicyManager
@ -81,7 +82,7 @@ class TableRLSManager implements RLSPolicyManager
$table = str($table)->afterLast('.')->toString();
// For each table, we get a list of all foreign key columns
$foreignKeys = collect($builder->getForeignKeys($table))->merge($this->getFakeForeignKeys($table))->map(function ($foreign) use ($table) {
$foreignKeys = collect($builder->getForeignKeys($table))->merge($this->getCommentConstraints($table))->map(function ($foreign) use ($table) {
return $this->formatForeignKey($foreign, $table);
});
@ -111,7 +112,7 @@ class TableRLSManager implements RLSPolicyManager
{
// 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)) {
if ($this->shouldSkipPathLeadingThrough($foreign)) {
return;
}
@ -125,48 +126,79 @@ class TableRLSManager implements RLSPolicyManager
$paths[] = $currentPath;
} else {
// If not, recursively generate paths for the foreign table
foreach (array_merge($this->database->getSchemaBuilder()->getForeignKeys($foreign['foreignTable'], $this->getFakeForeignKeys($table))) as $nextConstraint) {
foreach (array_merge(
$this->database->getSchemaBuilder()->getForeignKeys($foreign['foreignTable']),
$this->getCommentConstraints($foreign['foreignTable'])
) as $nextConstraint) {
$this->generatePaths($table, $this->formatForeignKey($nextConstraint, $foreign['foreignTable']), $paths, $currentPath);
}
}
}
protected function getFakeForeignKeys(string $tableName): array
protected function shouldSkipPathLeadingThrough(array $foreignKey): bool
{
// 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
$pathExplicitlySkipped = $foreignKey['comment'] === 'no-rls';
$pathImplicitlySkipped = ! static::$scopeByDefault && (
! isset($foreignKey['comment']) ||
(is_string($foreignKey['comment']) && ! Str::startsWith($foreignKey['comment'], 'rls'))
);
return $pathExplicitlySkipped || $pathImplicitlySkipped;
}
/**
* Retrieve comment-based constraints for a table. These are columns with comments in the format:
* "rls <foreign_table>.<foreign_column>"
*
* Throws an exception if the comment is formatted incorrectly or if the referenced table/column does not exist.
*/
protected function getCommentConstraints(string $tableName): array
{
$columns = $this->database->getSchemaBuilder()->getColumns($tableName);
$schemaBuilder = $this->database->getSchemaBuilder();
$fakeForeignKeys = array_filter($columns, function ($column) {
// Constraint comment should be "rls <foreign_table>.<foreign_column>"
if ($column['comment']) {
return Str::contains($column['comment'], 'rls ');
}
return false;
$commentConstraints = array_filter($columns, function ($column) {
return (isset($column['comment']) && is_string($column['comment']))
&& Str::startsWith($column['comment'], 'rls ');
});
if ($tableName === 'non_constrained_posts') {
dump(array_map(function ($fakeForeignKey) {
// Constraint comment is "rls <foreign_table>.<foreign_column>"
$constraint = explode('.', Str::after($fakeForeignKey['comment'], 'rls '));
return array_map(function ($commentConstraint) use ($schemaBuilder, $tableName) {
$comment = $commentConstraint['comment'];
$constraintString = Str::after($comment, 'rls ');
$constraint = explode('.', $constraintString);
return [
'foreign_table' => $constraint[0],
'foreign_columns' => [$constraint[1]],
'columns' => [$fakeForeignKey['name']],
];
}, $fakeForeignKeys));
}
// Validate comment constraint format
if (count($constraint) !== 2 || empty($constraint[0]) || empty($constraint[1])) {
throw new RLSCommentConstraintException("Incorrectly formatted comment constraint on {$tableName}.{$commentConstraint['name']}: '{$comment}'");
}
return array_map(function ($fakeForeignKey) {
// Constraint comment is "rls <foreign_table>.<foreign_column>"
$constraint = explode('.', Str::after($fakeForeignKey['comment'], 'rls '));
$foreignTable = $constraint[0];
$foreignColumn = $constraint[1];
// Validate table existence
$allTables = array_map(function ($table) {
return str($table)->afterLast('.')->toString();
}, $schemaBuilder->getTableListing(schema: $this->database->getConfig('search_path')));
if (! in_array($foreignTable, $allTables, true)) {
throw new RLSCommentConstraintException("Comment constraint on {$tableName}.{$commentConstraint['name']} references non-existent table '{$foreignTable}'");
}
// Validate column existence
$foreignColumns = $schemaBuilder->getColumns($foreignTable);
$foreignColumnNames = array_column($foreignColumns, 'name');
if (! in_array($foreignColumn, $foreignColumnNames, true)) {
throw new RLSCommentConstraintException("Comment constraint on {$tableName}.{$commentConstraint['name']} references non-existent column '{$foreignTable}.{$foreignColumn}'");
}
return [
'foreign_table' => $constraint[0],
'foreign_columns' => [$constraint[1]],
'columns' => [$fakeForeignKey['name']],
'foreign_table' => $foreignTable,
'foreign_columns' => [$foreignColumn],
'columns' => [$commentConstraint['name']],
];
}, $fakeForeignKeys);
}, $commentConstraints);
}
/** Get tree's non-nullable paths. */
@ -215,7 +247,7 @@ class TableRLSManager implements RLSPolicyManager
}
/**
* Formats the foreign key array retrieved by Postgres to a more readable format.
* 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,
* and the foreign key column comment. These additional details are removed

View file

@ -664,6 +664,12 @@ test('table manager ignores recursive relationship if the foreign key responsibl
});
test('table manager can generate paths leading through non-constrained foreign keys', function() {
// Drop extra tables
Schema::dropIfExists('reactions');
Schema::dropIfExists('comments');
Schema::dropIfExists('posts');
Schema::dropIfExists('authors');
Schema::create('non_constrained_users', function (Blueprint $table) {
$table->id();
$table->string('tenant_id')->comment('rls tenants.id'); // "fake" constraint
@ -678,58 +684,6 @@ test('table manager can generate paths leading through non-constrained foreign k
$manager = app(TableRLSManager::class);
$expectedTrees = [
'authors' => [
// Directly related to tenants
'tenant_id' => [
[
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => false,
]
],
],
],
'comments' => [
// Tree starting from the post_id foreign key
'post_id' => [
[
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'author_id',
'foreignTable' => 'authors',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => false,
],
],
[
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => true,
],
],
],
],
'non_constrained_posts' => [
'author_id' => [
[
@ -762,34 +716,6 @@ test('table manager can generate paths leading through non-constrained foreign k
]
],
],
'posts' => [
'author_id' => [
[
[
'foreignKey' => 'author_id',
'foreignTable' => 'authors',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => false,
]
],
],
'tenant_id' => [
[
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => true,
]
]
],
],
];
expect($manager->generateTrees())->toEqual($expectedTrees);