Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MongoDB Example Implementation For Reference #1310

Open
alexc-hollywood opened this issue Feb 7, 2025 · 2 comments
Open

MongoDB Example Implementation For Reference #1310

alexc-hollywood opened this issue Feb 7, 2025 · 2 comments
Assignees
Labels
feature New feature or request

Comments

@alexc-hollywood
Copy link

Spent a lot of time battling with this (excellent, beloved package) today to make it work with MongoDB. For anyone who is thinking of doing the same, here's the info to save you the time. Laravel's documentation now includes info on MongoDB and Sail also includes the PHP extension by default.

First up: Mongo creates things on the fly. Writing a database manager requires getting hold of the underlying database client using getMongoClient(). A database MUST have at least one collection in it.

<?php

namespace Example\Tenancy;

use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Contracts\TenantDatabaseManager;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Exceptions\NoConnectionSetException;

use MongoDB\Client;

class MongoDBDatabaseManager implements TenantDatabaseManager
{
    protected $connection;

    protected function client(): Client
    {
        if ($this->connection === null)
        {
            throw new NoConnectionSetException(static::class);
        }

        return $this->database()->getMongoClient();
    }

    protected function database()
    {
        if ($this->connection === null)
        {
            throw new NoConnectionSetException(static::class);
        }

        return DB::connection($this->connection);
    }

    public function setConnection(string $connection): void
    {
        $this->connection = $connection;
    }

    public function createDatabase(TenantWithDatabase $tenant): bool
    {
        $this->client()
            ->selectDatabase($tenant->database()->getName())
            ->createCollection('safe_to_remove');

        return true;
    }

    public function deleteDatabase(TenantWithDatabase $tenant): bool
    {
        $this->client()
            ->dropDatabase($tenant->database()->getName());

        return true;
    }

    public function databaseExists(string $name): bool
    {
        foreach ($this->client()->listDatabaseNames() as $database)
        {
            if ($database === $name)
            {
                return true;
            }
        }

        return false;
    }

    public function makeConnectionConfig(array $baseConfig, string $databaseName): array
    {
        $baseConfig['database'] = $databaseName;

        return $baseConfig;
    }
}

In config/tenancy.php, it needs to be added as a driver:

        'managers' => [
           // others, e.g. MySQL, PG
            'mongodb' => Example\Tenancy\MongoDBDatabaseManager::class,
        ],

Next up, the default models (Domain, Impersonate, Tenant, etc) have to extend the MongoDB driver, and specify collection and keys. The key type is CRUCIAL here to avoid errors because it defaults to "id" which does not exist.

use MongoDB\Laravel\Eloquent\Model;

class Tenant extends Model implements Contracts\Tenant
{
    use Concerns\CentralConnection,
        Concerns\GeneratesIds,
        VirtualColumn,
        Concerns\HasInternalKeys,
        Concerns\TenantRun,
        Concerns\InvalidatesResolverCache;

    protected static $modelsShouldPreventAccessingMissingAttributes = false;

    protected $collection = 'tenants';
    protected $guarded = [];
    protected $primaryKey = '_id';
    protected $keyType = 'string';

    public function getTenantKeyName(): string
    {
        return '_id';
    }
}

In config/tenancy.php, they need to be replaced with your own:

    'tenant_model' => \Example\Models\ReplacedBaseTenantModel::class,
    'domain_model' => \Example\Models\ReplacedBaseDomainModel::class,

Finally, the crucial piece: the "data" column. This isn't needed in Mongo because it's a giant JSON document. The problem here is VirtualColumn (being replaced in v4?) because as it cycles through an object attributes to store them, it chokes on Mongo's datetime fields.

Two functions need altering in the VirtualColumn class:

    protected function decodeVirtualColumn(): void
    {
        if (!$this->dataEncoded) {
            return;
        }

        $encryptedCastables = array_merge(
            static::$customEncryptedCastables,
            ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object'],
        );

        $customColumns = static::getCustomColumns();
        $attributes = array_filter($this->getAttributes(), fn ($key) => ! in_array($key, $customColumns), ARRAY_FILTER_USE_KEY);

        foreach ($attributes as $key => $value) {
            $attributeHasEncryptedCastable = in_array(data_get($this->getCasts(), $key), $encryptedCastables);

            // Special handling for MongoDB date objects
            if (is_array($value) && isset($value['$date'])) {
                $this->attributes[$key] = $value;
                continue;
            }

            if ($value && $attributeHasEncryptedCastable && $this->valueEncrypted($value)) {
                $this->attributes[$key] = $value;
            } else {
                $this->setAttribute($key, $value);
            }

            $this->syncOriginalAttribute($key);
        }

        $this->dataEncoded = true;
    }

    protected function encodeAttributes(): void
    {
        if ($this->dataEncoded) {
            return;
        }

        $dataColumn = static::getDataColumn();
        $customColumns = static::getCustomColumns();
        $attributes = array_filter($this->getAttributes(), fn ($key) => ! in_array($key, $customColumns), ARRAY_FILTER_USE_KEY);

        // Instead of nesting in data column, merge attributes directly into the document
        foreach ($attributes as $key => $value) {
            $this->attributes[$key] = $value;
        }

        $this->dataEncoded = true;
    }

For query checking with Debugbar, do this in AppServiceProvider::boot():

if (config('app.debug')) DB::connection('mongodb')->enableQueryLog();
@alexc-hollywood alexc-hollywood added the feature New feature or request label Feb 7, 2025
@stancl
Copy link
Member

stancl commented Feb 7, 2025

Thanks for the detailed write-up!

The problem here is VirtualColumn (being replaced in v4?)

It's not being replaced but there will be a new major version of virtualcolumn. If you think there's something we can address in that package to improve compatibility with these setups, could you open an issue in that repo? Will take a look at it 👍

@alexc-hollywood
Copy link
Author

Sure thing. It may not need to be touched at all when using Mongo really, as it's idempotent and the whole document is a virtualcolumn (so to speak). So in pseudo, if using sql rdbms, use VC; if using nosql, just add fields in any way you like. Ish. Kinda.

Btw this package needs to be part of the Laravel core. I've found myself using it in most projects so i don't have to go back later to add it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants