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

[10.x] backport #52188 #52293

Merged
merged 1 commit into from
Jul 29, 2024
Merged

[10.x] backport #52188 #52293

merged 1 commit into from
Jul 29, 2024

Conversation

calebdw
Copy link
Contributor

@calebdw calebdw commented Jul 26, 2024

Hello!

We still have an application on 10.x and are suffering the same failures referenced in the original issue. This backports #52188 to 10.x

Thanks!

@calebdw calebdw changed the base branch from 11.x to 10.x July 26, 2024 14:17
@calebdw calebdw changed the title [10.xfix: Model/JsonResource::toJson should not fail with prior json errors [10.x] backport #52188 Jul 26, 2024
@taylorotwell taylorotwell merged commit 82e9dbc into laravel:10.x Jul 29, 2024
3 checks passed
@calebdw calebdw deleted the model_toJson branch July 29, 2024 12:31
@calebdw
Copy link
Contributor Author

calebdw commented Jul 29, 2024

Thanks!

@Arkhas
Copy link

Arkhas commented Aug 5, 2024

We have a big issue with this PR, since this update, we are getting a InvalidArgumentException in a lot of our response when returning directly a Model from a controller like :

Route::get('/users', function () {
    return User::all();
});

reverting this PR totally fix this issue

The issue is coming from casting an empty column to array : how to reproduce :

$user = new User(['foo' => '']);
$user->mergeCasts(['foo' => 'array']);

 $user->toJson();

// 4 with this PR, 0 without
dd(json_last_error());

so catching this in this function :

         try {
            $json = json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR);
        } catch (JsonException $e) {
            throw JsonEncodingException::forModel($this, $e->getMessage());
        }

is causing the exception to be thrown

@calebdw
Copy link
Contributor Author

calebdw commented Aug 5, 2024

That means there's a failed json_encode/decode earlier in the application. Could be due to #52204 which I have not backported, but I can do that real quick

@calebdw calebdw mentioned this pull request Aug 5, 2024
@Arkhas
Copy link

Arkhas commented Aug 5, 2024

That means there's a failed json_encode/decode earlier in the application. Could be due to #52204 which I have not backported, but I can do that real quick

I edited my message, but it seems that is indeed from the same issue, but I don't think the JSON_THROW_ON_ERROR should be directly into the Model class

@Arkhas
Copy link

Arkhas commented Aug 5, 2024

In this PR you completly disable the option to give 0 as $options so it will never be possible to give the "0" flag to the json_encode function, if you want to use JSON_THROW_ON_ERROR then you should use it when you manualy call the toJson() function :

return $model->toJson(JSON_THROW_ON_ERROR);

Anyway, this a breaking change, and this should not be in a minor update in laravel 10

@calebdw
Copy link
Contributor Author

calebdw commented Aug 5, 2024

In this PR you completly disable the option to give 0 as $options so it will never be possible to give the "0" flag to the json_encode function, if you want to use JSON_THROW_ON_ERROR then you should use it when you manualy call the toJson() function :

No, this is correct---the model was throwing an exception anyway if there was a json error. The issue that I fixed was that the exception was being thrown for a previous json error even when the model was successfully converted to json.

@Arkhas
Copy link

Arkhas commented Aug 5, 2024

you can try it doing this in a controller :

$user = new User(['foo' => '']);
$user->mergeCasts(['foo' => 'array']);

return  $user;

it was not crashing before, and crash now

@calebdw
Copy link
Contributor Author

calebdw commented Aug 5, 2024

This is because the system relies on global state which is a bad idea and very brittle as you can see.

This PR specifically fixed a bug where a Js::from($model) call in a blade template was throwing exceptions even when the model was successfully being converted to json. In fact, I removed the dependency on global state from the Model and JsonResource. The fact that other areas are now failing as a result of depending on global state is not the fault of this PR nor does it mean that this PR should be reverted.

I've opened a PR which backports the request json_decode issue (#52389), however, the code really needs to be cleaned up to not rely on global state as this error you are experiencing could still happen if other json errors occur earlier in the application

@Arkhas
Copy link

Arkhas commented Aug 6, 2024

I aggre that it should throw an error when dealing with faulty Json, but this is not my point.

• This change is a breaking change and cause any model using an empty array casted column to crash the app
• You are totally breaking the existing function, as passing 0 to the toJson() function is not working anymore

At least it should be notted as a breaking change in the upgrade to Laravel 10 Documentation

@taylorotwell what is your opinion on the matter ?

@calebdw
Copy link
Contributor Author

calebdw commented Aug 6, 2024

You are totally breaking the existing function, as passing 0 to the toJson() function is not working anymore

This is not a breaking change because the Model::toJson method still threw a JsonEncodingException even if 0 was passed to the method

image

@Arkhas
Copy link

Arkhas commented Aug 7, 2024

well, just without your changes, try that :

$user = new User(['foo' => '']);
$user->mergeCasts(['foo' => 'array']);

return  $user;

Before, the user would be return, and now, you have an exception making the app to crash

@calebdw
Copy link
Contributor Author

calebdw commented Aug 7, 2024

BLUF

An exception is being thrown when returning a Model from a controller that has an array-casted attribute set to an empty string (an empty string is not valid json).

First and foremost, this is data integrity issue---database columns that are cast to json/array should not be EMPTY, they should either be NULL or contain valid json.

However, it would be possible to prevent this parse error by converting falsely json values to a json null ('null') before decoding:

// src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
    public function fromJson($value, $asObject = false)
    {
-       return Json::decode($value ?? '', ! $asObject);
+       return Json::decode($value ?: 'null', ! $asObject);
    }

Analysis

I've stepped through the framework with a debugger and here's what I've found:

The exception is being thrown by the JsonResponse::setData method (note the json_decode call before encoding the data):

public function setData($data = []): static
{
$this->original = $data;
// Ensure json_last_error() is cleared...
json_decode('[]');
$this->data = match (true) {
$data instanceof Jsonable => $data->toJson($this->encodingOptions),
$data instanceof JsonSerializable => json_encode($data->jsonSerialize(), $this->encodingOptions),
$data instanceof Arrayable => json_encode($data->toArray(), $this->encodingOptions),
default => json_encode($data, $this->encodingOptions),
};
if (! $this->hasValidJson(json_last_error())) {
throw new InvalidArgumentException(json_last_error_msg());
}
return $this->update();
}

A Model is Jsonable so the first match arm is executed, an exception is not being thrown here so we know that this json_encode call is succeeding:

public function toJson($options = 0)
{
try {
$json = json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw JsonEncodingException::forModel($this, $e->getMessage());
}
return $json;
}

Model::jsonSerialize() is just an alias for Model::toArray() which casts the attributes before returning the array. If a value is null and it's a primitive cast (which array is) then the cast operation is skipped:

protected function castAttribute($key, $value)
{
$castType = $this->getCastType($key);
if (is_null($value) && in_array($castType, static::$primitiveCastTypes)) {
return $value;
}

Otherwise the attribute is casted to an array:

case 'array':
case 'json':
return $this->fromJson($value);

public function fromJson($value, $asObject = false)
{
return Json::decode($value ?? '', ! $asObject);
}

Which leads to Json::decode, and this is where the json_last_error is introduced---because an empty string is not valid json:

public static function decode(mixed $value, ?bool $associative = true): mixed
{
return isset(static::$decoder)
? (static::$decoder)($value, $associative)
: json_decode($value, $associative);
}

@donnysim
Copy link
Contributor

donnysim commented Aug 7, 2024

I get the reasoning about that it shouldn't have worked, but you've proved yourself that it is a breaking change even if it shouldn't have worked before. Even I have apps that still run on old MySQL 5 with TEXT fields for json and some have empty data for good or bad reasons, and the argument that your data is wrong does not make it any different that a change broke what was working before without any notice or warning. So this should either go all the way to fix it or not fix it at all in a minor release.

@Arkhas
Copy link

Arkhas commented Aug 9, 2024

Thank you all :)

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.

None yet

4 participants