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#10292 Controlled Vocab DAO to Eloquent Model #10324

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

Conversation

touhidurabir
Copy link
Member

for #10292

* @param int $assocType DO NOT USE: For <3.1 to 3.x migration pkp/pkp-lib#6213
*
*/
public function scopeGetAgencies(
Copy link
Member Author

@touhidurabir touhidurabir Aug 20, 2024

Choose a reason for hiding this comment

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

I feel like using the scope methods for these in these extended model classes does not make sense . It does not utilise the scope's methods capabilities and here we had to pass the $builder which has no use . Perhaps a simple static method is good enough .

same for all similar methods .

Copy link
Member

Choose a reason for hiding this comment

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

Just looking at the DO NOT USE note -- is this code that got left over after the 3.0.x to 3.1.x upgrade got removed? (Also more below.)

Comment on lines 30 to 33
public function scopeGetLocaleFieldNames(Builder $query): array
{
return ['submissionAgency'];
}
Copy link
Member Author

@touhidurabir touhidurabir Aug 20, 2024

Choose a reason for hiding this comment

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

Not sure if these methods have any use (here is other similar class to get the locale field name)

Copy link
Member

@asmecher asmecher left a comment

Choose a reason for hiding this comment

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

Thanks, I've added some early review comments!

classes/controlledVocab/ControlledVocab.php Outdated Show resolved Hide resolved
classes/controlledVocab/ControlledVocab.php Outdated Show resolved Hide resolved
classes/controlledVocab/Repository.php Outdated Show resolved Hide resolved
classes/publication/DAO.php Outdated Show resolved Hide resolved
* @param int $assocType DO NOT USE: For <3.1 to 3.x migration pkp/pkp-lib#6213
*
*/
public function scopeGetAgencies(
Copy link
Member

Choose a reason for hiding this comment

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

Just looking at the DO NOT USE note -- is this code that got left over after the 3.0.x to 3.1.x upgrade got removed? (Also more below.)

use PKP\core\PKPApplication;


class SubmissionAgencyVocab extends ControlledVocab
Copy link
Member

Choose a reason for hiding this comment

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

Do you think we could get rid of these classes entirely? If all we're doing is providing a convenience getAgencies function, rather than withSymbolic(SOME_AGENCIES_CONSTANT, ...), then I don't think it's worth the extra classes.

Copy link
Member Author

Choose a reason for hiding this comment

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

All of there classes have been removed . By removing these extra classes and few related others , our code also reduced significantly .

classes/user/UserInterest.php Outdated Show resolved Hide resolved
@touhidurabir touhidurabir force-pushed the i10292_main branch 3 times, most recently from d68630a to 3e91b7a Compare October 8, 2024 03:58
@touhidurabir touhidurabir marked this pull request as ready for review October 8, 2024 11:58
fn ($query) => $query->withSymbolic($symbolic)->withAssoc($assocType, $assocId)
)
->withLocale($locale)
->withSetting($symbolic, $value)
Copy link
Member Author

Choose a reason for hiding this comment

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

here previously setting name passed as name but is that correct as for controlled vocabs we have specific settings ?

Comment on lines +26 to +41
public function up(): void
{
Schema::table('controlled_vocab_entry_settings', function (Blueprint $table) {
$table->dropColumn('setting_type');
});
}

/**
* Reverse the migration.
*/
public function down(): void
{
Schema::table('controlled_vocab_entry_settings', function (Blueprint $table) {
$table->string('setting_type', 6);
});
}
Copy link
Member Author

Choose a reason for hiding this comment

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

dropping the column setting_type seems reasonable as it seems to have no use and our newer eloquent based implementation for setting tables does not have support for it either .

return $controlledVocabDao->enumerate($this->getId(), $settingName);
}
}
public function enumerate(?string $settingName = null): array
Copy link
Member Author

Choose a reason for hiding this comment

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

Here $settingName previously have default value to name but that seems not right as for controlled vocab, we have a specific sets to settings only . Or I am missing something ?

'controlledVocabEntryId' => $interestId,
]));

Repo::controlledVocab()->resequence($controlledVocab->id);
Copy link
Member Author

Choose a reason for hiding this comment

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

do we need to resequence the vocabs for user interest, don't see any benefit or usage of it ?

Comment on lines +36 to +40
$assocId = (DB::table("publications")
->select("publication_id as id")
->orderBy("publication_id", "desc")
->first()
->id ?? 0) + 100;
Copy link
Member Author

Choose a reason for hiding this comment

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

rather than setting a fixed value like 333, it's better to build one based on what we have in DB and add a const value (like here 100) to just to be safe .

Comment on lines +42 to +83
$testControlledVocab = Repo::controlledVocab()->build(
ControlledVocab::CONTROLLED_VOCAB_SUBMISSION_KEYWORD,
Application::ASSOC_TYPE_CITATION,
$assocId
);

// Mock the ControlledVocabDAO
$mockControlledVocabDao = $this->getMockBuilder(ControlledVocabDAO::class)
->onlyMethods(['getBySymbolic'])
->getMock();
$controlledVocabEntryId1 = ControlledVocabEntry::create([
'controlledVocabId' => $testControlledVocab->id,
ControlledVocab::CONTROLLED_VOCAB_SUBMISSION_KEYWORD => [
'en' => 'testEntry',
],
])->id;

// Set up the mock getBySymbolic() method
$mockControlledVocabDao->expects($this->any())
->method('getBySymbolic')
->with('testVocab', Application::ASSOC_TYPE_CITATION, 333)
->willReturn($mockControlledVocab);

DAORegistry::registerDAO('ControlledVocabDAO', $mockControlledVocabDao);
$controlledVocabEntryId2 = ControlledVocabEntry::create([
'controlledVocabId' => $testControlledVocab->id,
ControlledVocab::CONTROLLED_VOCAB_SUBMISSION_KEYWORD => [
'en' => 'testEntry',
],
])->id;

// Instantiate validator
$validator = new \PKP\form\validation\FormValidatorControlledVocab($form, 'testData', FormValidator::FORM_VALIDATOR_REQUIRED_VALUE, 'some.message.key', 'testVocab', Application::ASSOC_TYPE_CITATION, 333);
$validator = new FormValidatorControlledVocab(
$form,
'testData',
FormValidator::FORM_VALIDATOR_REQUIRED_VALUE,
'some.message.key',
ControlledVocab::CONTROLLED_VOCAB_SUBMISSION_KEYWORD,
Application::ASSOC_TYPE_CITATION,
$assocId
);

$form->setData('testData', '1');
$form->setData('testData', $controlledVocabEntryId1);
self::assertTrue($validator->isValid());

$form->setData('testData', '2');
$form->setData('testData', $controlledVocabEntryId2);
self::assertTrue($validator->isValid());

$form->setData('testData', '3');
$form->setData('testData', 3);
self::assertFalse($validator->isValid());

// Delete the test vocab along with entries
ControlledVocab::find($testControlledVocab->id)->delete();
Copy link
Member Author

@touhidurabir touhidurabir Oct 8, 2024

Choose a reason for hiding this comment

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

With implementation of setting table based eloquent, we can not add any arbitrary setting data so only options seems to create data and delete at the end of test . Another option can be is to backup/restore specific tables or full DB but that make the test time high. For this and other similar tests t below .

Comment on lines 79 to 129
// check if new settings value or multilingual value added and store those to DB
$modelWithGivenSettings = static::find($this->model->getKey(), $settingValues->keys()->toArray());
$rows = [];
$settingValues->each(function (mixed $settingValue, string $settingName) use ($modelWithGivenSettings, &$rows) {
$settingName = Str::camel($settingName);

if (!$modelWithGivenSettings->{$settingName} || is_array($modelWithGivenSettings->{$settingName})) {
if ($this->isMultilingual($settingName)) {
$existingLocales = array_keys($modelWithGivenSettings->{$settingName} ?? []);

foreach ($settingValue as $locale => $localizedValue) {
if (in_array($locale, $existingLocales)) {
continue;
}

$rows[] = [
$this->model->getKeyName() => $this->model->getKey(),
'locale' => $locale,
'setting_name' => $settingName,
'setting_value' => $localizedValue,
];
}
} else {
$rows[] = [
$this->model->getKeyName() => $this->model->getKey(),
'locale' => '',
'setting_name' => $settingName,
'setting_value' => $settingValue,
];
}
}
});

if (count($rows) > 0) {
DB::table($this->model->getSettingsTable())->insert($rows);
}
Copy link
Member Author

Choose a reason for hiding this comment

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

@Vitaliy-1 this particular change is seems required as when updating a model instance with new settings/multilingual column, it does not add the data in the settings table . for example , a announcement model data may not have descriptionShort or may have descriptionShort for only en . But if we try to update it as following

$announcement->update([
    "descriptionShort" => [
        "en" => "<p>Test Announcement 2025 nothing</p>"
        "fr_CA" => "<p>something new</p>"
    ],
]);

it will not add a new row in the settings table for fr_CA which is expected . I think this will cause issue when updating a multilingual or normal setting field as it will never get inserted . Please take a look of above implementation .

Copy link
Collaborator

Choose a reason for hiding this comment

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

Good spot! It seems that my idea to update settings in 1 query doesn't work if the value wasn't set. Considering the example query:

UPDATE announcement_settings SET setting_value = 
CASE WHEN setting_name='title' AND locale='en' THEN 'English Title' WHEN setting_name='title' AND locale='fr_CA' THEN 'French TItle' ELSE setting_value END
WHERE announcement_id = 16;

I really don't now SQL well enough to say if we can modify it to create a new row if it doesn't exist.
On the other hand, we can just run a series of conventional sql queries to update existing rows or insert new ones. This is a quick example: https://github.com/pkp/pkp-lib/pull/10542/files
Let me know if you want me to resolve this bug

Copy link
Member Author

Choose a reason for hiding this comment

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

@Vitaliy-1 I am not sure also how to update and insert in a single query and the query may become too complex to work in future too . However I think we need to consider 3 cases here

  1. Updating the existing ones in table in passed through update request .
  2. Adding new ones that do not exists in table passed through update request
  3. deleting what is not passed in the update request .

for for example , if an announcement model instance have 4 settings row for locales en, fr_CA, fr_FR, pt_BR, for title prop and we make a request like

$announcement->update([
    "title" => [
        "en" => "Title in EN"
        "fr_CA" => "Title in fr_CA",
        "fr_FR" => "Title in fr_FR",
        "el" => "Title in el",
    ],
]);

So in the above example , we updating en, fr_CA, fr_FR , adding new el and no details about pt_BR which need to be removed . I have added an implementation at https://github.com/pkp/pkp-lib/pull/10324/files#diff-4dcc09fe73d537128c17c4bbb260ad4ba24ccc0ecb8cdc7d0b47ebc7986e2cedR79-R129 but that run 3 queries(update, insert and delete) .

Maybe we can optimize in 2 queries by deleting all for that given props and inserting again. What you think ?

Copy link
Collaborator

Choose a reason for hiding this comment

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

In general, on the object level, all looks OK. All three conditions are met. So in theory we should have all needed info to update data once per row. Looking into it

Copy link
Collaborator

Choose a reason for hiding this comment

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

Aha, we have PKPSchemaService::addMissingMultilingualValues() for API calls that ensures consistency. And we don't have the same logic for Models.

Comment on lines +334 to +364

// TODO : Investigate how to handle the camel to snake case issue. related to pkp/pkp-lib#10485
$settingColumns = $this->model->getSettings();
$columns = collect($columns)
->map(
fn (string $column): string => in_array($column, $settingColumns)
? $column
: Str::snake($column)
)
->toArray();

foreach ($row as $property => $value) {
if (!in_array($property, $columns) && isset($row->{$property})) {
Copy link
Member Author

Choose a reason for hiding this comment

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

@Vitaliy-1 this change seems required as with current implementation , we do not get the desired outcome with specifying the columns as such

$announcement = Announcement::find(31, ["title"]);

Also throws a lot of warnings . Please take a look .

Comment on lines 71 to 92
} else {
if (!empty($this->fillable)) {
$this->mergeFillable(array_merge($this->getSettings(), $this->getMultilingualProps()));
}
Copy link
Member Author

Choose a reason for hiding this comment

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

@Vitaliy-1 this changes seems required to populate the proper fillable as in the if condition, we are only considering entities with schema maps but also need to consider for entities with settings with no schema map .

Comment on lines +204 to +247
protected function isGuardableColumn($key)
{
// Need the snake like to key to check for main table to compare with column listing
$key = Str::snake($key);

if (! isset(static::$guardableColumns[get_class($this)])) {
$columns = $this->getConnection()
->getSchemaBuilder()
->getColumnListing($this->getTable());

if (empty($columns)) {
return true;
}
static::$guardableColumns[get_class($this)] = $columns;
}


$settingsWithMultilingual = array_merge($this->getSettings(), $this->getMultilingualProps());
$camelKey = Str::camel($key);

// Check if this column included in setting and multilingula props and not set to guarded
if (in_array($camelKey, $settingsWithMultilingual) && !in_array($camelKey, $this->getGuarded())) {
return true;
}

return in_array($key, (array)static::$guardableColumns[get_class($this)]);
}
Copy link
Member Author

Choose a reason for hiding this comment

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

@Vitaliy-1 this override of \Illuminate\Database\Eloquent\Concerns\GuardsAttributes::isGuardableColumn seems required when defining any guarded attributes for entities with no schema map as for schema mapped entities, we always generate the fillable .


return [$key => array_filter($value)];
}
}
Copy link
Member Author

Choose a reason for hiding this comment

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

related to #10476 .

Comment on lines +82 to +88
$this->generateAttributeCast(
collect($this->getMultilingualProps())
->flatMap(
fn (string $attribute): array => [$attribute => MultilingualSettingAttribute::class]
)
->toArray()
);
Copy link
Member Author

Choose a reason for hiding this comment

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

related to #10476

Comment on lines +177 to +192

$propCast[$propName] = isset($propSchema->multilingual) && $propSchema->multilingual == true
? MultilingualSettingAttribute::class
: $propSchema->type;
}

$propCasts = $this->ensureCastsAreStringValues($propCast);
$this->casts = array_merge($propCasts, $this->casts);
$this->generateAttributeCast($propCast);
}

/**
* Generate the final cast from dynamically generated attr casts
*/
protected function generateAttributeCast(array $attrCast): void
{
$attrCasts = $this->ensureCastsAreStringValues($attrCast);
$this->casts = array_merge($attrCasts, $this->casts);
Copy link
Member Author

Choose a reason for hiding this comment

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

related to #10476

Comment on lines +79 to +129
// check if new settings value or multilingual value added and store those to DB
$modelWithGivenSettings = static::find($this->model->getKey(), $settingValues->keys()->toArray());
$rows = [];
$settingValues->each(function (mixed $settingValue, string $settingName) use ($modelWithGivenSettings, &$rows) {
$settingName = Str::camel($settingName);

if (!$modelWithGivenSettings->{$settingName} || is_array($modelWithGivenSettings->{$settingName})) {

if ($this->isMultilingual($settingName)) {
$existingLocales = array_keys($modelWithGivenSettings->{$settingName} ?? []);
$removeableLocales = array_diff($existingLocales, array_keys($settingValue));

foreach ($settingValue as $locale => $localizedValue) {
// Will not add setting entry for locale that already exist
// as that will be handled by update
if (in_array($locale, $existingLocales)) {
continue;
}

// Only add those new locale entries that newly added
$rows[] = [
$this->model->getKeyName() => $this->model->getKey(),
'locale' => $locale,
'setting_name' => $settingName,
'setting_value' => $localizedValue,
];
}

// As the model multilingula attributed is gettting updated
// remove any locale entry associated with setting name not present in the update data need to be removed
if (!empty($removeableLocales)) {
DB::table($this->model->getSettingsTable())
->where($this->model->getKeyName(), $this->model->getKey())
->where('setting_name', $settingName)
->whereIn('locale', $removeableLocales)
->delete();
}
} else {
$rows[] = [
$this->model->getKeyName() => $this->model->getKey(),
'locale' => '',
'setting_name' => $settingName,
'setting_value' => $settingValue,
];
}
}
});

if (count($rows) > 0) {
DB::table($this->model->getSettingsTable())->insert($rows);
}
Copy link
Member Author

@touhidurabir touhidurabir Oct 22, 2024

Choose a reason for hiding this comment

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

@Vitaliy-1 updated implementation to handle the update with multilingual prop . continuation of #10324 (comment)

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.

3 participants