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

pkp/pkp-lib#10328 Refactor Announcements #10382

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

Vitaliy-1
Copy link
Collaborator

No description provided.

&& (
$announcement->getDateExpire() == null || strtotime($announcement->getDateExpire()) > time()
$announcement->getAttribute('dateExpire') == null || strtotime($announcement->getAttribute('dateExpire')) > time()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be possible to use Eloquent date casts to get columns to come back automagically as Carbon objects; it shouldn't be necessary/desirable to use strtotime anymore.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The case here is bit different here than the usual . we can have a cast define but as our types are coming from schema which will over write it in the ModelWithSettings trait's convertSchemaToCasts method where we are calling the mergeCasts which merge the provided casts with pre defined ones . I think if we want to apply some casts like

/**
 * Get the attributes that should be cast.
 *
 * @return array<string, string>
 */
protected function casts(): array
{
    return [
        'dateExpire' => 'datetime',
        'datePosted' => 'datetime',
    ];
}

we need to override the Illuminate\Database\Eloquent\Concerns\HasAttributes::mergeCasts to defined how it will handle and determine the precedence of provided casts over defined ones .

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about this a bit more, I think we should override the method mergeCasts inside the trait ModelWithSettings to allow model defined casts over write any casts coming form schema if schema available like

/**
     * Merge new casts with existing casts on the model.
     *
     * @param  array  $casts
     * @return $this
     */
    public function mergeCasts($casts)
    {
        $casts = $this->ensureCastsAreStringValues($casts);
        dump($this->casts);
        $this->casts = array_merge($casts, $this->casts);

        return $this;
    }

basically we are just altering how the array_merge getting done .

As trait ModelWithSettings will defined directly in entity model classes, it's mergeCasts should have higher precedence of HasAttributes::mergeCasts used in Illuminate\Database\Eloquent\Model .

@@ -8,13 +8,15 @@
"properties": {
"_href": {
"type": "string",
"origin": "composed",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Like this!


// TODO Eloquent transforms attributes to snake case, find and override instead of transforming here
$settingValues = $settingValues->mapWithKeys(
fn (mixed $value, string $key) => [Str::camel($key) => $value]
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seams there is additional conversion of attributes between camel case and snake case happening somewhere in the Eloquent core. Not critical, can be handled later

$settingCount = DB::table($us)->whereIn($us . '.' . $primaryKey, $newQuery->select($primaryKey))
->update([$us . '.setting_value' => DB::raw($sql)]);

return $count ? $count + $settingCount : $settingCount; // TODO Return the count of updated setting rows?
Copy link
Collaborator Author

@Vitaliy-1 Vitaliy-1 Sep 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what is better to return here since we updating two tables at once?

Copy link
Contributor

@Hafsa-Naeem Hafsa-Naeem Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Vitaliy-1 Instead of returning a single summed count, could we return an associative array with separate counts for the primary table and the settings table?
we can also use database transaction (DB::transaction) to ensure that both updates either succeed or fail together to maintain data integrity.

}

// TODO What should the default behaviour be if localized value doesn't exist?
return $multilingualProp[$locale] ?? null;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think if multilingual value doesn't exists it's better to just return null

->filterByContextIds([$context->getId()])
);
// TODO is it OK to delete without listening Model's delete-associated events (not loading each Model)?
Announcement::withContextIds([$context->getId])->delete();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleting without models instantiating, it's much more performant

Copy link
Member

@touhidurabir touhidurabir left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Vitaliy-1 added reviews . These are more like thoughts, suggestions and questions than reviews . Feel free to adopt if you feel like those fit the use cases .

BTW, I must say, this is very promising and will help us adopting eloquent more generally.

$sendEmail = (bool) filter_var($params['sendEmail'], FILTER_VALIDATE_BOOLEAN);

if ($context) {
$this->notifyUsers($request, $context, $announcementId, $sendEmail);
$this->notifyUsers($request, $context, $announcement->getKey(), $sendEmail);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps rather than accessing the primary key as getKey, how about setting a attribute setter/getter for primary key in the Announcement model as

protected function id(): Attribute
{
        return Attribute::make(
            get: fn($value, $attributes) => $attributes[$this->primaryKey] ?? null,
            set: fn($value) => [$this->primaryKey => $value],
        );
    }

This will allow us the access the primary key as $modelInstance->id which is easier and cleaner . Also we all are trying to adopt this approach .

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, let's try. There is a possibility that it might interact with schema's id attribute, I don't see anything right now but id is a primary attribute and the logic I've put to the new schema setting origin defines Mutators and composed attributes. Meaning in announcement.json:

"id": {
	"type": "integer",
	"origin": "primary",
	"readOnly": true,
	"apiSummary": true
},

{
$this->setData('assocType', $assocType);
}
protected $fillable = ['assocType', 'assocId', 'typeId', 'title', 'image', 'description', 'descriptionShort'];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this may cause more issue than it will solve in future for us , specially for entities with schema maps with a JSON file . As we can add more props to schema via Hook like Hook::add('Schema::get::' . PKPSchemaService::SCHEMA_ANNOUNCEMENT, [$this, 'someFunction']); , but as we have predefined fillable, eloquent will discard anything and everything not defined in the property $fillable .

In fact in my experience this cause more confusion and frustration even when add new column via migration but forget the update the $fillable. And this above cases where we can override props via scheme hooks, this more to that case .

However we need fillable also as otherwise any unwanted params will casue exception for example in PKPAnnouncementController::add, we have params passed to create as

array:8 [▼
  "title" => array:2 [▶]
  "descriptionShort" => array:2 [▶]
  "description" => array:2 [▶]
  "image" => null
  "dateExpire" => null
  "sendEmail" => null
  "assocType" => 256
  "assocId" => 1
]

where we have no column named sendEmail .

How about we generate the fillable dynamically like

protected function constructFillable(): void
{
    $schemaService = app()->get('schema'); /** @var PKPSchemaService $schemaService */
    $primaryColumns = $schemaService->groupPropsByOrigin(static::getSchemaName())[Schema::ATTRIBUTE_ORIGIN_MAIN];
    $this->fillable = array_merge($this->fillable, $primaryColumns, $this->settings);
}

and then have it from ModelWithSettings::__construct as

public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);

        if (static::getSchemaName()) {
            $this->setSchemaData();
            $this->constructFillable();
        }
    }

and in the models , we only defined a $guarded as

protected $guarded = ['id'];

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that few entities don't have schema, probably best to allow the generation of fillable not depending on the schema . That is something like

public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);

        if (static::getSchemaName()) {
            $this->setSchemaData();
        }

        $this->constructFillable();
    }
protected function constructFillable(): void
    {
        $primaryColumns = [];

        if (static::getSchemaName()) {
            $schemaService = app()->get('schema'); /** @var PKPSchemaService $schemaService */
            $primaryColumns = $schemaService->groupPropsByOrigin(static::getSchemaName())[Schema::ATTRIBUTE_ORIGIN_MAIN];
        }
        
        $this->fillable = array_merge($this->fillable, $primaryColumns, $this->settings);
    }

classes/announcement/Announcement.php Show resolved Hide resolved
/**
* See Illuminate\Database\Eloquent\Concerns\HasAttributes::mergeCasts()
*/
abstract public function mergeCasts(array $casts);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason we are defining the mergeCasts as abstract here as it's already defined in the Illuminate\Database\Eloquent\Concerns\HasAttributes which used as a trait in Illuminate\Database\Eloquent\Model ? unless this trait is being used in non eloquent entity classes, do we have any use of it here ?

public function __construct(array $attributes = [])
{
parent::__construct($attributes);
if ($this->getSchemaName()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see we are defining the method getSchemaName as static but then accessing as class instance method . I know laravel's handle it internally but perhaps better for us to stick with the general convention when we can . Or there any particular reason I am missing ?

Comment on lines +110 to +120
$multilingualProp = $this->getAttribute($data);
if (!$multilingualProp) {
throw new Exception('Attribute ' . $data . ' doesn\'t exist in the ' . static::class . ' model');
}

if (!in_array($data, $this->multilingualProps)) {
throw new Exception('Trying to retrieve localized data from a non-multilingual attribute ' . $data);
}

// TODO What should the default behaviour be if localized value doesn't exist?
return $multilingualProp[$locale] ?? null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be for a model instance, for some unforeseen reason, it may not contains localized data even when the localized property still defines for the model/schema . However checking via the getAttribute, we will only get what right now set for database retrieved model instance and which may not contain any details of localized property. may be better to check if the multilingual props actually exists on model/schema and make decision based on that aviability like

if (!in_array($data, $this->getMultilingualProps())) {
    throw new Exception(
        sprintf("Given localizeble property %s does not exists in %s model", $data, static::class)
    );
}

$multilingualProp = $this->getAttribute($data);

// TODO What should the default behaviour be if localized value doesn't exist?
return $multilingualProp[$locale] ?? null;

&& (
$announcement->getDateExpire() == null || strtotime($announcement->getDateExpire()) > time()
$announcement->getAttribute('dateExpire') == null || strtotime($announcement->getAttribute('dateExpire')) > time()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The case here is bit different here than the usual . we can have a cast define but as our types are coming from schema which will over write it in the ModelWithSettings trait's convertSchemaToCasts method where we are calling the mergeCasts which merge the provided casts with pre defined ones . I think if we want to apply some casts like

/**
 * Get the attributes that should be cast.
 *
 * @return array<string, string>
 */
protected function casts(): array
{
    return [
        'dateExpire' => 'datetime',
        'datePosted' => 'datetime',
    ];
}

we need to override the Illuminate\Database\Eloquent\Concerns\HasAttributes::mergeCasts to defined how it will handle and determine the precedence of provided casts over defined ones .

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

Successfully merging this pull request may close these issues.

4 participants