diff --git a/composer.json b/composer.json index 4d56a6cc2..0e6c31cb4 100644 --- a/composer.json +++ b/composer.json @@ -83,10 +83,6 @@ } }, "autoload-dev": { - "classmap": [ - "tests/TestCase.php", - "tests/DbTestCase.php" - ], "psr-4": { "Winter\\Storm\\Tests\\": "tests/" } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a02929e4a..0e626dc55 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -135,21 +135,6 @@ parameters: count: 1 path: src/Database/QueryBuilder.php - - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" - count: 1 - path: src/Database/Relations/AttachMany.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/AttachMany.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/AttachMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 @@ -170,21 +155,6 @@ parameters: count: 1 path: src/Database/Relations/AttachMany.php - - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" - count: 1 - path: src/Database/Relations/AttachOne.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/AttachOne.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/AttachOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 @@ -210,26 +180,11 @@ parameters: count: 3 path: src/Database/Relations/AttachOne.php - - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" - count: 1 - path: src/Database/Relations/BelongsTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:bindEventOnce\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsTo.php - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/BelongsTo.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/BelongsTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 @@ -246,12 +201,12 @@ parameters: path: src/Database/Relations/BelongsTo.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$sessionKey\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsTo\\:\\:getForeignKey\\(\\)\\.$#" count: 1 - path: src/Database/Relations/BelongsToMany.php + path: src/Database/Relations/BelongsTo.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" + message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$sessionKey\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php @@ -291,85 +246,89 @@ parameters: path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Query\\\\Builder\\:\\:lists\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:flushDuplicateCache\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Query\\\\Builder\\:\\:lists\\(\\)\\.$#" + message: "#^Parameter \\#2 \\$columns of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:paginate\\(\\) expects array, int\\|null given\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:flushDuplicateCache\\(\\)\\.$#" + message: "#^Parameter \\#2 \\$columns of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:simplePaginate\\(\\) expects array, int\\|null given\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Parameter \\#2 \\$columns of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\::(simpleP|p)aginate\\(\\) expects array, int\\|null given\\.$#" - count: 2 + message: "#^Parameter \\#2 \\$currentPage \\(int\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:paginate\\(\\) should be compatible with parameter \\$columns \\(array\\\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:paginate\\(\\)$#" + count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Parameter \\#2 \\$currentPage \\(int(\\|null)?\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany::(simpleP|p)aginate\\(\\) should be compatible with parameter \\$columns \\(array\\\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\::(simpleP|p)aginate\\(\\)$#" - count: 2 + message: "#^Parameter \\#2 \\$currentPage \\(int\\|null\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:simplePaginate\\(\\) should be compatible with parameter \\$columns \\(array\\\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:simplePaginate\\(\\)$#" + count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Parameter \\#3 \\$columns \\(array\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany::(simpleP|p)aginate\\(\\) should be compatible with parameter \\$pageName \\(string\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\::(simpleP|p)aginate\\(\\)$#" - count: 2 + message: "#^Parameter \\#3 \\$columns \\(array\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:paginate\\(\\) should be compatible with parameter \\$pageName \\(string\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:paginate\\(\\)$#" + count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Parameter \\#3 \\$pageName of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\::(simpleP|p)aginate\\(\\) expects string, array given\\.$#" - count: 2 + message: "#^Parameter \\#3 \\$columns \\(array\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:simplePaginate\\(\\) should be compatible with parameter \\$pageName \\(string\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:simplePaginate\\(\\)$#" + count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Parameter \\#4 \\$pageName \\(string\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany::(simpleP|p)aginate\\(\\) should be compatible with parameter \\$page \\(int\\|null\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\::(simpleP|p)aginate\\(\\)$#" - count: 2 + message: "#^Parameter \\#3 \\$pageName of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:paginate\\(\\) expects string, array given\\.$#" + count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Parameter \\#4 \\$page of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\::(simpleP|p)aginate\\(\\) expects int\\|null, string given.$#" - paths: - - src/Database/Relations/BelongsToMany.php - - src/Database/Relations/MorphToMany.php + message: "#^Parameter \\#3 \\$pageName of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:simplePaginate\\(\\) expects string, array given\\.$#" + count: 1 + path: src/Database/Relations/BelongsToMany.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" + message: "#^Parameter \\#4 \\$page of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:paginate\\(\\) expects int\\|null, string given\\.$#" count: 1 - path: src/Database/Relations/HasMany.php + path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Parameter \\#4 \\$page of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:simplePaginate\\(\\) expects int\\|null, string given\\.$#" count: 1 - path: src/Database/Relations/HasMany.php + path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Parameter \\#4 \\$pageName \\(string\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:paginate\\(\\) should be compatible with parameter \\$page \\(int\\|null\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:paginate\\(\\)$#" count: 1 - path: src/Database/Relations/HasMany.php + path: src/Database/Relations/BelongsToMany.php + + - + message: "#^Parameter \\#4 \\$pageName \\(string\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:simplePaginate\\(\\) should be compatible with parameter \\$page \\(int\\|null\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:simplePaginate\\(\\)$#" + count: 1 + path: src/Database/Relations/BelongsToMany.php - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" @@ -401,21 +360,6 @@ parameters: count: 1 path: src/Database/Relations/HasMany.php - - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" - count: 1 - path: src/Database/Relations/HasManyThrough.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/HasManyThrough.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/HasManyThrough.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 @@ -431,26 +375,6 @@ parameters: count: 1 path: src/Database/Relations/HasManyThrough.php - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getQualifiedFirstKeyName\\(\\)\\.$#" - count: 12 - path: src/Database/Relations/Concerns/DefinedConstraints.php - - - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" - count: 1 - path: src/Database/Relations/HasOne.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/HasOne.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/HasOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 @@ -471,21 +395,6 @@ parameters: count: 2 path: src/Database/Relations/HasOne.php - - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" - count: 1 - path: src/Database/Relations/HasOneThrough.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/HasOneThrough.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/HasOneThrough.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 @@ -502,19 +411,9 @@ parameters: path: src/Database/Relations/HasOneThrough.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" - count: 1 - path: src/Database/Relations/MorphMany.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/MorphMany.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/MorphMany.php + message: "#^Instanceof between \\*NEVER\\* and Winter\\\\Storm\\\\Database\\\\Relations\\\\HasManyThrough will always evaluate to false\\.$#" + count: 4 + path: src/Database/Relations/HasOneThrough.php - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" @@ -532,50 +431,26 @@ parameters: path: src/Database/Relations/MorphMany.php - - message: "#^Call to private method update\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\MorphMany\\\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphMany\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphMany.php - - - message: "#^Call to private method whereNotIn\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\MorphMany\\\\.$#" - count: 1 - path: src/Database/Relations/MorphMany.php - - - - message: "#^If condition is always true\\.$#" - count: 1 - path: src/Database/Relations/MorphMany.php - - message: "#^Parameter \\#1 \\$query of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\MorphOneOrMany\\\\:\\:__construct\\(\\) expects Illuminate\\\\Database\\\\Eloquent\\\\Builder\\, Illuminate\\\\Database\\\\Eloquent\\\\Builder\\ given\\.$#" + message: "#^Call to private method update\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\MorphMany\\\\.$#" count: 1 path: src/Database/Relations/MorphMany.php - - message: "#^Parameter \\$parent of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphMany\\:\\:__construct\\(\\) has invalid type Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\TRelatedModel\\.$#" + message: "#^Call to private method whereNotIn\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\MorphMany\\\\.$#" count: 1 path: src/Database/Relations/MorphMany.php - - message: "#^Parameter \\$query of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphMany\\:\\:__construct\\(\\) has invalid type Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\TRelatedModel\\.$#" + message: "#^If condition is always true\\.$#" count: 1 path: src/Database/Relations/MorphMany.php - - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" - count: 1 - path: src/Database/Relations/MorphOne.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/MorphOne.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/MorphOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 @@ -592,123 +467,123 @@ parameters: path: src/Database/Relations/MorphOne.php - - message: "#^Call to private method update\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\MorphOne\\\\.$#" - count: 2 - path: src/Database/Relations/MorphOne.php - - - - message: "#^Parameter \\#1 \\$query of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\MorphOneOrMany\\\\:\\:__construct\\(\\) expects Illuminate\\\\Database\\\\Eloquent\\\\Builder\\, Illuminate\\\\Database\\\\Eloquent\\\\Builder\\ given\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphOne\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphOne.php - - message: "#^Parameter \\$parent of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphOne\\:\\:__construct\\(\\) has invalid type Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\TRelatedModel\\.$#" - count: 1 + message: "#^Call to private method update\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\MorphOne\\\\.$#" + count: 2 path: src/Database/Relations/MorphOne.php - - message: "#^Parameter \\$query of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphOne\\:\\:__construct\\(\\) has invalid type Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\TRelatedModel\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:bindEventOnce\\(\\)\\.$#" count: 1 - path: src/Database/Relations/MorphOne.php + path: src/Database/Relations/MorphTo.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:bindEventOnce\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphTo\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphTo.php - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 - path: src/Database/Relations/MorphTo.php + path: src/Database/Relations/MorphToMany.php - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" count: 1 - path: src/Database/Relations/MorphTo.php + path: src/Database/Relations/MorphToMany.php - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" count: 1 - path: src/Database/Relations/MorphTo.php + path: src/Database/Relations/MorphToMany.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Query\\\\Builder\\:\\:lists\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:flushDuplicateCache\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Parameter \\#2 \\$columns of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:paginate\\(\\) expects array, int\\|null given\\.$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" + message: "#^Parameter \\#2 \\$columns of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:simplePaginate\\(\\) expects array, int\\|null given\\.$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" + message: "#^Parameter \\#2 \\$currentPage \\(int\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:paginate\\(\\) should be compatible with parameter \\$columns \\(array\\\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:paginate\\(\\)$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" + message: "#^Parameter \\#2 \\$currentPage \\(int\\|null\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:simplePaginate\\(\\) should be compatible with parameter \\$columns \\(array\\\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:simplePaginate\\(\\)$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Query\\\\Builder\\:\\:lists\\(\\)\\.$#" + message: "#^Parameter \\#3 \\$columns \\(array\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:paginate\\(\\) should be compatible with parameter \\$pageName \\(string\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:paginate\\(\\)$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:flushDuplicateCache\\(\\)\\.$#" + message: "#^Parameter \\#3 \\$columns \\(array\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:simplePaginate\\(\\) should be compatible with parameter \\$pageName \\(string\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:simplePaginate\\(\\)$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Parameter \\#2 \\$columns of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\::(simpleP|p)aginate\\(\\) expects array, int\\|null given\\.$#" - count: 2 + message: "#^Parameter \\#3 \\$pageName of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:paginate\\(\\) expects string, array given\\.$#" + count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Parameter \\#2 \\$currentPage \\(int(\\|null)?\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany::(simpleP|p)aginate\\(\\) should be compatible with parameter \\$columns \\(array\\\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\::(simpleP|p)aginate\\(\\)$#" - count: 2 + message: "#^Parameter \\#3 \\$pageName of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:simplePaginate\\(\\) expects string, array given\\.$#" + count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Parameter \\#3 \\$columns \\(array\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany::(simpleP|p)aginate\\(\\) should be compatible with parameter \\$pageName \\(string\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\::(simpleP|p)aginate\\(\\)$#" - count: 2 + message: "#^Parameter \\#4 \\$page of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:paginate\\(\\) expects int\\|null, string given\\.$#" + count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Parameter \\#3 \\$pageName of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\::(simpleP|p)aginate\\(\\) expects string, array given\\.$#" - count: 2 + message: "#^Parameter \\#4 \\$page of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:simplePaginate\\(\\) expects int\\|null, string given\\.$#" + count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Parameter \\#4 \\$pageName \\(string\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany::(simpleP|p)aginate\\(\\) should be compatible with parameter \\$page \\(int\\|null\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\::(simpleP|p)aginate\\(\\)$#" - count: 2 + message: "#^Parameter \\#4 \\$pageName \\(string\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:paginate\\(\\) should be compatible with parameter \\$page \\(int\\|null\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:paginate\\(\\)$#" + count: 1 + path: src/Database/Relations/MorphToMany.php + + - + message: "#^Parameter \\#4 \\$pageName \\(string\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:simplePaginate\\(\\) should be compatible with parameter \\$page \\(int\\|null\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:simplePaginate\\(\\)$#" + count: 1 path: src/Database/Relations/MorphToMany.php - @@ -726,6 +601,16 @@ parameters: count: 1 path: src/Database/TreeCollection.php + - + message: "#^Call to an undefined method Illuminate\\\\Routing\\\\Router\\:\\:after\\(\\)\\.$#" + count: 1 + path: src/Foundation/Application.php + + - + message: "#^Call to an undefined method Illuminate\\\\Routing\\\\Router\\:\\:before\\(\\)\\.$#" + count: 1 + path: src/Foundation/Application.php + - message: "#^Parameter \\#2 \\$data \\(array\\) of method Winter\\\\Storm\\\\Mail\\\\Mailer\\:\\:queue\\(\\) should be compatible with parameter \\$queue \\(string\\|null\\) of method Illuminate\\\\Contracts\\\\Mail\\\\MailQueue\\:\\:queue\\(\\)$#" count: 1 @@ -775,13 +660,3 @@ parameters: message: "#^Call to function is_null\\(\\) with Closure will always evaluate to false\\.$#" count: 1 path: src/Validation/Factory.php - - - - message: "#^Call to an undefined method Illuminate\\\\Routing\\\\Router\\:\\:before\\(\\)\\.$#" - count: 1 - path: src/Foundation/Application.php - - - - message: "#^Call to an undefined method Illuminate\\\\Routing\\\\Router\\:\\:after\\(\\)\\.$#" - count: 1 - path: src/Foundation/Application.php diff --git a/phpstan.neon b/phpstan.neon index 291360afc..37b9e0a2a 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -12,6 +12,10 @@ parameters: - src/Parse/PHP/ArrayPrinter.php - src/Foundation/Console/KeyGenerateCommand.php - src/Scaffold/GeneratorCommand.php + ignoreErrors: + - message: '#Call to private method select\(\)#' + paths: + - src/Database/Relations/Concerns/CanBeCounted.php disableSchemaScan: true databaseMigrationsPath: - src/Auth/Migrations diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5db8edbf7..552bbd2a7 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,6 +2,7 @@ ./tests + + + + diff --git a/src/Database/Attributes/Relation.php b/src/Database/Attributes/Relation.php new file mode 100644 index 000000000..b11f7c736 --- /dev/null +++ b/src/Database/Attributes/Relation.php @@ -0,0 +1,55 @@ + HasOne::class, + 'hasMany' => HasMany::class, + 'belongsTo' => BelongsTo::class, + 'belongsToMany' => BelongsToMany::class, + 'morphTo' => MorphTo::class, + 'morphOne' => MorphOne::class, + 'morphMany' => MorphMany::class, + 'morphToMany' => MorphToMany::class, + 'morphedByMany' => MorphToMany::class, + 'attachOne' => AttachOne::class, + 'attachMany' => AttachMany::class, + 'hasOneThrough' => HasOneThrough::class, + 'hasManyThrough' => HasManyThrough::class, + ]; + + public function __construct(string $type) + { + if (in_array($type, array_keys(static::$relationTypes))) { + $this->type = $type; + } elseif (in_array($type, array_values(static::$relationTypes))) { + $this->type = array_search($type, static::$relationTypes); + } else { + throw new \Exception('Invalid relation type'); + } + } + + public function getType(): string + { + return $this->type; + } +} diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 8946ed8af..60b1ee31e 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -1,7 +1,13 @@ - HasOne::class, + 'hasMany' => HasMany::class, + 'belongsTo' => BelongsTo::class, + 'belongsToMany' => BelongsToMany::class, + 'morphTo' => MorphTo::class, + 'morphOne' => MorphOne::class, + 'morphMany' => MorphMany::class, + 'morphToMany' => MorphToMany::class, + 'morphedByMany' => MorphToMany::class, + 'attachOne' => AttachOne::class, + 'attachMany' => AttachMany::class, + 'hasOneThrough' => HasOneThrough::class, + 'hasManyThrough' => HasManyThrough::class, ]; + /** + * @var array> Stores methods that have resolved to Laravel-style relation objects by class name. + */ + protected static array $resolvedRelationMethods = []; + + /** + * @var array> Stores methods that have not been resolved to Laravel-style relation objects by class name. + * Used mainly for testing extension methods. + */ + protected static array $resolvedNonRelationMethods = []; + // // Relations // /** * Checks if model has a relationship by supplied name. - * @param string $name Relation name */ - public function hasRelation($name): bool + public function hasRelation(string $name, bool $propertyOnly = false): bool + { + $this->detectRelationConflict($name); + + if (!$propertyOnly && $this->isRelationMethod($name, true)) { + return true; + } + + return $this->getRelationDefinition($name, false) !== null; + } + + /** + * Gets the name and relation object of all defined relations on the model. + * + * This differs from the `getRelations()` method provided by Laravel, which only returns the loaded relations. It + * also contains the Relation object as a value, rather than the result of the relation as per Laravel's + * implementation. + */ + public function getDefinedRelations(): array { - return $this->getRelationDefinition($name) !== null; + $relations = []; + + foreach (array_keys(static::$relationTypes) as $type) { + foreach (array_keys($this->getRelationTypeDefinitions($type, false)) as $name) { + $relations[$name] = $this->handleRelation($name, false); + } + } + + foreach ($this->getRelationMethods(true) as $relation) { + $relations[$relation] = $this->{$relation}(); + } + + return $relations; } /** * Returns relationship details from a supplied name. - * @param string $name Relation name + * + * If the name resolves to a relation method, the method's returned relation object will be converted back to an + * array definition. */ - public function getRelationDefinition($name): ?array + public function getRelationDefinition(string $name, bool $includeMethods = true): ?array { - if (($type = $this->getRelationType($name)) !== null) { - return (array) $this->getRelationTypeDefinition($type, $name) + $this->getRelationDefaults($type); + $this->detectRelationConflict($name); + + if ($includeMethods && $this->isRelationMethod($name)) { + return $this->relationMethodDefinition($name); + } + + if (($type = $this->getRelationType($name, $includeMethods)) !== null) { + return (array) $this->getRelationTypeDefinition($type, $name, $includeMethods) + $this->getRelationDefaults($type); } return null; @@ -163,27 +236,36 @@ public function getRelationDefinition($name): ?array /** * Returns all defined relations of given type. - * @param string $type Relation type - * @return array|string|null */ - public function getRelationTypeDefinitions($type) + public function getRelationTypeDefinitions(string $type, bool $includeMethods = true): array { - if (in_array($type, static::$relationTypes)) { - return $this->{$type}; + $relations = []; + + if (in_array($type, array_keys(static::$relationTypes))) { + $relations = array_map(function ($relation) { + return (is_string($relation)) ? [$relation] : $relation; + }, $this->{$type}); + + if ($includeMethods) { + foreach ($this->getRelationMethods() as $method) { + if ($this->getRelationMethodType($method) === $type) { + $relations[$method] = $this->relationMethodDefinition($method); + } + } + } } - return []; + return $relations; } /** * Returns the given relation definition. - * @param string $type Relation type - * @param string $name Relation name - * @return string|null + * + * If no relation exists by the given name and type, `null` will be returned. */ - public function getRelationTypeDefinition($type, $name) + public function getRelationTypeDefinition(string $type, string $name, bool $includeMethods = true): array|null { - $definitions = $this->getRelationTypeDefinitions($type); + $definitions = $this->getRelationTypeDefinitions($type, $includeMethods); if (isset($definitions[$name])) { return $definitions[$name]; @@ -195,12 +277,12 @@ public function getRelationTypeDefinition($type, $name) /** * Returns relationship details for all relations defined on this model. */ - public function getRelationDefinitions(): array + public function getRelationDefinitions(bool $includeMethods = true): array { $result = []; - foreach (static::$relationTypes as $type) { - $result[$type] = $this->getRelationTypeDefinitions($type); + foreach (array_keys(static::$relationTypes) as $type) { + $result[$type] = $this->getRelationTypeDefinitions($type, $includeMethods); /* * Apply default values for the relation type @@ -218,10 +300,14 @@ public function getRelationDefinitions(): array /** * Returns a relationship type based on a supplied name. */ - public function getRelationType(string $name): ?string + public function getRelationType(string $name, bool $includeMethods = true): ?string { - foreach (static::$relationTypes as $type) { - if ($this->getRelationTypeDefinition($type, $name) !== null) { + if ($includeMethods && $this->isRelationMethod($name)) { + return $this->getRelationMethodType($name); + } + + foreach (array_keys(static::$relationTypes) as $type) { + if ($this->getRelationTypeDefinition($type, $name, $includeMethods) !== null) { return $type; } } @@ -246,12 +332,12 @@ public function makeRelation(string $name): ?Model } /** - * Determines whether the specified relation should be saved - * when push() is called instead of save() on the model. Default: true. - * @param string $name Relation name - * @return boolean + * Save relation on push. + * + * Determines whether the specified relation should be saved when `push()` is called on the model, instead of + * `save()`. By default, this will be true. */ - public function isRelationPushable($name) + public function isRelationPushable(string $name): bool { $definition = $this->getRelationDefinition($name); if (is_null($definition) || !array_key_exists('push', $definition)) { @@ -262,11 +348,9 @@ public function isRelationPushable($name) } /** - * Returns default relation arguments for a given type. - * @param string $type Relation type - * @return array + * Returns default relation arguments for a given relation type. */ - protected function getRelationDefaults($type) + protected function getRelationDefaults(string $type): array { switch ($type) { case 'attachOne': @@ -279,520 +363,760 @@ protected function getRelationDefaults($type) } /** - * Looks for the relation and does the correct magic as Eloquent would require - * inside relation methods. For more information, read the documentation of the mentioned property. - * @param string $relationName the relation key, camel-case version - * @return \Illuminate\Database\Eloquent\Relations\Relation + * Detects if the relation is specified both as a relation method and in the relation properties. + * + * If the relation is defined in both places, an exception will be thrown. + * + * @throws \Winter\Storm\Exception\SystemException */ - protected function handleRelation($relationName) + protected function detectRelationConflict(string $name): void { + if ( + $this->getRelationType($name, false) !== null + && $this->isRelationMethod($name) + ) { + throw new SystemException(sprintf( + 'Relation "%s" in model "%s" is defined both as a relation method and in the relation properties config.', + $name, + get_called_class() + )); + } + } + + /** + * Creates a Laravel relation object from a Winter relation definition array. + * + * Winter has traditionally used array properties in the model to configure relationships. This method converts + * these to the applicable Laravel relation object. + */ + protected function handleRelation(string $relationName, bool $addConstraints = true): EloquentRelation + { + $this->detectRelationConflict($relationName); + $relationType = $this->getRelationType($relationName); - $relation = $this->getRelationDefinition($relationName); + $definition = $this->getRelationDefinition($relationName); + $relatedClass = $definition[0] ?? null; - if (!isset($relation[0]) && $relationType != 'morphTo') { + if (!isset($relatedClass) && $relationType != 'morphTo') { throw new InvalidArgumentException(sprintf( - "Relation '%s' on model '%s' should have at least a classname.", + "Relation '%s' on model '%s' must specify a related class name.", $relationName, get_called_class() )); } - if (isset($relation[0]) && $relationType == 'morphTo') { + if (isset($relatedClass) && $relationType == 'morphTo') { throw new InvalidArgumentException(sprintf( - "Relation '%s' on model '%s' is a morphTo relation and should not contain additional arguments.", + "Relation '%s' on model '%s' is a morphTo relation and should not specify a related class name.", $relationName, get_called_class() )); } + // Create relation object based on relation switch ($relationType) { case 'hasOne': + $relation = $this->hasOne( + $relatedClass, + $definition['key'] ?? null, + $definition['otherKey'] ?? null, + ); + break; case 'hasMany': - $relation = $this->validateRelationArgs($relationName, ['key', 'otherKey']); - $relationObj = $this->$relationType($relation[0], $relation['key'], $relation['otherKey'], $relationName); + $relation = $this->hasMany( + $relatedClass, + $definition['key'] ?? null, + $definition['otherKey'] ?? null, + ); break; - case 'belongsTo': - $relation = $this->validateRelationArgs($relationName, ['key', 'otherKey']); - $relationObj = $this->$relationType($relation[0], $relation['key'], $relation['otherKey'], $relationName); + $relation = $this->belongsTo( + $relatedClass, + $definition['key'] ?? null, + $definition['otherKey'] ?? null, + $relationName, + ); break; - case 'belongsToMany': - $relation = $this->validateRelationArgs($relationName, ['table', 'key', 'otherKey', 'parentKey', 'relatedKey', 'pivot', 'timestamps']); - $relationObj = $this->$relationType($relation[0], $relation['table'], $relation['key'], $relation['otherKey'], $relation['parentKey'], $relation['relatedKey'], $relationName); - - if (isset($relation['pivotModel'])) { - $relationObj->using($relation['pivotModel']); + $relation = $this->belongsToMany( + $relatedClass, + $definition['table'] ?? null, + $definition['key'] ?? null, + $definition['otherKey'] ?? null, + $definition['parentKey'] ?? null, + $definition['relatedKey'] ?? null, + $relationName, + ); + if (isset($definition['pivotModel'])) { + $relation->using($definition['pivotModel']); } - break; - case 'morphTo': - $relation = $this->validateRelationArgs($relationName, ['name', 'type', 'id']); - $relationObj = $this->$relationType($relation['name'] ?: $relationName, $relation['type'], $relation['id']); + $relation = $this->morphTo( + $definition['name'] ?? $relationName, + $definition['type'] ?? null, + $definition['id'] ?? null, + $definition['otherKey'] ?? null, + ); break; - case 'morphOne': + $relation = $this->morphOne( + $relatedClass, + $definition['name'] ?? $relationName, + $definition['type'] ?? null, + $definition['id'] ?? null, + $definition['key'] ?? null, + ); + break; case 'morphMany': - $relation = $this->validateRelationArgs($relationName, ['type', 'id', 'key'], ['name']); - $relationObj = $this->$relationType($relation[0], $relation['name'], $relation['type'], $relation['id'], $relation['key'], $relationName); + $relation = $this->morphMany( + $relatedClass, + $definition['name'] ?? $relationName, + $definition['type'] ?? null, + $definition['id'] ?? null, + $definition['key'] ?? null, + ); break; - case 'morphToMany': - $relation = $this->validateRelationArgs($relationName, ['table', 'key', 'otherKey', 'parentKey', 'relatedKey', 'pivot', 'timestamps'], ['name']); - $relationObj = $this->$relationType($relation[0], $relation['name'], $relation['table'], $relation['key'], $relation['otherKey'], $relation['parentKey'], $relation['relatedKey'], false, $relationName); - - if (isset($relation['pivotModel'])) { - $relationObj->using($relation['pivotModel']); + $relation = $this->morphToMany( + $relatedClass, + $definition['name'] ?? $relationName, + $definition['table'] ?? null, + $definition['key'] ?? null, + $definition['otherKey'] ?? null, + $definition['parentKey'] ?? null, + $definition['relatedKey'] ?? null, + $definition['inverse'] ?? false, + ); + if (isset($definition['pivotModel'])) { + $relation->using($definition['pivotModel']); } - break; - case 'morphedByMany': - $relation = $this->validateRelationArgs($relationName, ['table', 'key', 'otherKey', 'parentKey', 'relatedKey', 'pivot', 'timestamps'], ['name']); - $relationObj = $this->$relationType($relation[0], $relation['name'], $relation['table'], $relation['key'], $relation['otherKey'], $relation['parentKey'], $relation['relatedKey'], $relationName); + $relation = $this->morphedByMany( + $relatedClass, + $definition['name'] ?? $relationName, + $definition['table'] ?? null, + $definition['key'] ?? null, + $definition['otherKey'] ?? null, + $definition['parentKey'] ?? null, + $definition['relatedKey'] ?? null, + ); break; - case 'attachOne': + $relation = $this->attachOne( + $relatedClass, + $definition['public'] ?? true, + $definition['key'] ?? null, + $definition['field'] ?? $relationName, + ); + if (isset($definition['delete']) && $definition['delete'] === false) { + $relation = $relation->notDependent(); + } + break; case 'attachMany': - $relation = $this->validateRelationArgs($relationName, ['public', 'key']); - $relationObj = $this->$relationType($relation[0], $relation['public'], $relation['key'], $relationName); + $relation = $this->attachMany( + $relatedClass, + $definition['public'] ?? true, + $definition['key'] ?? null, + $definition['field'] ?? $relationName, + ); + if (isset($definition['delete']) && $definition['delete'] === false) { + $relation = $relation->notDependent(); + } break; - case 'hasOneThrough': + $relation = $this->hasOneThrough( + $relatedClass, + $definition['through'], + $definition['key'] ?? null, + $definition['throughKey'] ?? null, + $definition['otherKey'] ?? null, + $definition['secondOtherKey'] ?? null, + ); + break; case 'hasManyThrough': - $relation = $this->validateRelationArgs($relationName, ['key', 'throughKey', 'otherKey', 'secondOtherKey'], ['through']); - $relationObj = $this->$relationType($relation[0], $relation['through'], $relation['key'], $relation['throughKey'], $relation['otherKey'], $relation['secondOtherKey'], $relationName); + $relation = $this->hasManyThrough( + $relatedClass, + $definition['through'], + $definition['key'] ?? null, + $definition['throughKey'] ?? null, + $definition['otherKey'] ?? null, + $definition['secondOtherKey'] ?? null, + ); break; - default: - throw new InvalidArgumentException(sprintf("There is no such relation type known as '%s' on model '%s'.", $relationType, get_called_class())); + throw new InvalidArgumentException( + sprintf( + 'There is no such relation type known as \'%s\' on model \'%s\'.', + $relationType, + get_called_class() + ) + ); } - return $relationObj; - } + // Add relation name + $relation->setRelationName($relationName); + + // Add dependency, if required + if ( + ($definition['delete'] ?? false) === true + && in_array( + \Winter\Storm\Database\Relations\Concerns\CanBeDependent::class, + class_uses_recursive($relation) + ) + ) { + $relation = $relation->dependent(); + } - /** - * Validate relation supplied arguments. - */ - protected function validateRelationArgs($relationName, $optional, $required = []) - { - $relation = $this->getRelationDefinition($relationName); + // Add soft delete, if required + if ( + ($definition['softDelete'] ?? false) === true + && in_array( + \Winter\Storm\Database\Relations\Concerns\CanBeSoftDeleted::class, + class_uses_recursive($relation) + ) + ) { + $relation = $relation->softDeletable(); + } - // Query filter arguments - $filters = ['scope', 'conditions', 'order', 'pivot', 'timestamps', 'push', 'count', 'default']; + // Remove detachable, if required + if ( + ($definition['detach'] ?? true) === false + && in_array( + \Winter\Storm\Database\Relations\Concerns\CanBeDetachable::class, + class_uses_recursive($relation) + ) + ) { + $relation = $relation->notDetachable(); + } - foreach (array_merge($optional, $filters) as $key) { - if (!array_key_exists($key, $relation)) { - $relation[$key] = null; - } + // Remove pushable flag, if required + if ( + ($definition['push'] ?? true) === false + && in_array( + \Winter\Storm\Database\Relations\Concerns\CanBePushed::class, + class_uses_recursive($relation) + ) + ) { + $relation = $relation->notPushable(); } - $missingRequired = []; - foreach ($required as $key) { - if (!array_key_exists($key, $relation)) { - $missingRequired[] = $key; - } + // Add count only flag, if required + if ( + ($definition['count'] ?? false) === true + && $addConstraints + && in_array( + \Winter\Storm\Database\Relations\Concerns\CanBeCounted::class, + class_uses_recursive($relation) + ) + ) { + $relation = $relation->countOnly(); } - if ($missingRequired) { - throw new InvalidArgumentException(sprintf( - 'Relation "%s" on model "%s" should contain the following key(s): %s', - $relationName, - get_called_class(), - implode(', ', $missingRequired) - )); + $relation->addDefinedConstraintsToRelation(); + + if ($addConstraints) { + // Add defined constraints + $relation->addDefinedConstraintsToQuery(); } return $relation; } /** - * Define a one-to-one relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\HasOne + * Finds the calling function name from the stack trace. */ - public function hasOne($related, $primaryKey = null, $localKey = null, $relationName = null) + protected function getRelationCaller(): ?string { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); - } + $trace = debug_backtrace(0); - $instance = $this->newRelatedInstance($related); - - $primaryKey = $primaryKey ?: $this->getForeignKey(); - - $localKey = $localKey ?: $this->getKeyName(); + $handled = Arr::first($trace, function ($trace) { + return $trace['function'] === 'handleRelation'; + }); - return new HasOne($instance->newQuery(), $this, $instance->getTable().'.'.$primaryKey, $localKey, $relationName); - } - - /** - * Define a polymorphic one-to-one relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\MorphOne - */ - public function morphOne($related, $name, $type = null, $id = null, $localKey = null, $relationName = null) - { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); + if (!is_null($handled)) { + return null; } - $instance = $this->newRelatedInstance($related); - - list($type, $id) = $this->getMorphs($name, $type, $id); + $currentKey = null; - $table = $instance->getTable(); + $caller = Arr::first($trace, function ($trace, $key) use (&$currentKey) { + $result = !in_array( + $trace['class'], + [ + \Illuminate\Database\Eloquent\Model::class, + \Winter\Storm\Database\Model::class, + ] + ); - $localKey = $localKey ?: $this->getKeyName(); + if ($result) { + $currentKey = $key; + } - return new MorphOne($instance->newQuery(), $this, $table.'.'.$type, $table.'.'.$id, $localKey, $relationName); - } + return $result; + }); - /** - * Define an inverse one-to-one or many relationship. - * Overridden from {@link Eloquent\Model} to allow the usage of the intermediary methods to handle the {@link - * $relationsData} array. - * @return \Winter\Storm\Database\Relations\BelongsTo - */ - public function belongsTo($related, $foreignKey = null, $parentKey = null, $relationName = null) - { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); - } + // If we're dealing with a closure, we're likely dealing with an extension. We'll need to go deeper! + if (str_contains($caller['function'], '{closure}')) { + [$stepOne, $stepTwo] = array_slice($trace, $currentKey + 1, 2); - $instance = $this->newRelatedInstance($related); + if ($stepOne['function'] !== 'call_user_func_array') { + return null; + } + if ($stepTwo['class'] !== \Winter\Storm\Database\Model::class || $stepTwo['function'] !== 'extendableCall') { + return null; + } - if (is_null($foreignKey)) { - $foreignKey = snake_case($relationName).'_id'; + return $stepTwo['args'][0]; } - $parentKey = $parentKey ?: $instance->getKeyName(); - - return new BelongsTo($instance->newQuery(), $this, $foreignKey, $parentKey, $relationName); + return !is_null($caller) ? $caller['function'] : null; } /** - * Define an polymorphic, inverse one-to-one or many relationship. - * Overridden from {@link Eloquent\Model} to allow the usage of the intermediary methods to handle the relation. - * @return \Winter\Storm\Database\Relations\MorphTo + * Returns a relation key value(s), not as an object. */ - public function morphTo($name = null, $type = null, $id = null, $ownerKey = null) + public function getRelationValue($relationName) { - if (is_null($name)) { - $name = $this->getRelationCaller(); - } - - list($type, $id) = $this->getMorphs(Str::snake($name), $type, $id); - - return empty($class = $this->{$type}) - ? $this->morphEagerTo($name, $type, $id, $ownerKey) - : $this->morphInstanceTo($class, $name, $type, $id, $ownerKey); + return $this->$relationName()->getSimpleValue(); } /** - * Define a polymorphic, inverse one-to-one or many relationship. - * - * @param string $name - * @param string $type - * @param string $id - * @param string $ownerKey - * @return \Winter\Storm\Database\Relations\MorphTo + * Sets a relation value directly from its attribute. */ - protected function morphEagerTo($name, $type, $id, $ownerKey) + protected function setRelationValue(string $relationName, $value): void { - return new MorphTo( - $this->newQuery()->setEagerLoads([]), - $this, - $id, - $ownerKey, - $type, - $name - ); + $this->$relationName()->setSimpleValue($value); } /** - * Define a polymorphic, inverse one-to-one or many relationship. - * - * @param string $target - * @param string $name - * @param string $type - * @param string $id - * @param string|null $ownerKey - * @return \Winter\Storm\Database\Relations\MorphTo + * Perform cascading deletions on related models that are dependent on the primary model. */ - protected function morphInstanceTo($target, $name, $type, $id, $ownerKey = null) + protected function performDeleteOnRelations(): void { - $instance = $this->newRelatedInstance( - static::getActualClassNameForMorph($target) - ); + $relations = $this->getDefinedRelations(); - return new MorphTo( - $instance->newQuery(), - $this, - $id, - $ownerKey ?? $instance->getKeyName(), - $type, - $name - ); + /** @var EloquentRelation */ + foreach (array_values($relations) as $relationObj) { + if (method_exists($relationObj, 'isDetachable') && $relationObj->isDetachable()) { + /** @var BelongsToMany|MorphToMany $relationObj */ + $relationObj->detach(); + } + if (method_exists($relationObj, 'isDependent') && $relationObj->isDependent()) { + $relationObj->get()->each(function ($model) { + $model->forceDelete(); + }); + } + } } /** - * Define a one-to-many relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\HasMany + * Retrieves all methods that either contain the `Relation` attribute or have a return type that matches a relation. */ - public function hasMany($related, $primaryKey = null, $localKey = null, $relationName = null) + public function getRelationMethods(bool $ignoreResolved = false): array { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); - } + $relationMethods = []; + + if ($ignoreResolved || !isset(static::$resolvedRelationMethods[static::class])) { + $validMethods = []; + $reflection = new \ReflectionClass($this); + + foreach ($reflection->getMethods() as $method) { + if ( + $method->getDeclaringClass()->getName() === DatabaseModel::class + || $method->getDeclaringClass()->getName() === Model::class + ) { + continue; + } + $validMethods[] = $method->getName(); + } - $instance = $this->newRelatedInstance($related); + foreach ($validMethods as $method) { + if (!in_array($method, ['attachOne', 'attachMany']) && $this->isRelationMethod($method)) { + $relationMethods[] = $method; + } + } + } else { + $relationMethods += array_keys(static::$resolvedRelationMethods[static::class]); + } - $primaryKey = $primaryKey ?: $this->getForeignKey(); + if (count($this->extensionData['methods'] ?? [])) { + foreach (array_keys($this->extensionData['methods']) as $name) { + if ($this->isRelationMethod($name)) { + $relationMethods[] = $name; + } + } + } - $localKey = $localKey ?: $this->getKeyName(); + if (count($this->extensionData['dynamicMethods'] ?? [])) { + foreach (array_keys($this->extensionData['dynamicMethods']) as $name) { + if ($this->isRelationMethod($name)) { + $relationMethods[] = $name; + } + } + } - return new HasMany($instance->newQuery(), $this, $instance->getTable().'.'.$primaryKey, $localKey, $relationName); + return $relationMethods; } /** - * Define a has-many-through relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\HasManyThrough + * Determine the relation type of a relation method. + * + * This is used to determine if a method is a relation method, first and foremost, and then to determine the type of the relation. */ - public function hasManyThrough($related, $through, $primaryKey = null, $throughKey = null, $localKey = null, $secondLocalKey = null, $relationName = null) + protected function getRelationMethodType(string $name, bool $ignoreResolved = false): ?string { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); + if (!$this->methodExists($name)) { + return null; } - $throughInstance = new $through; + if (!$ignoreResolved) { + if (isset(static::$resolvedRelationMethods[static::class][$name])) { + return static::$resolvedRelationMethods[static::class][$name]['type']; + } - $primaryKey = $primaryKey ?: $this->getForeignKey(); + if ( + isset(static::$resolvedNonRelationMethods[static::class]) + && in_array($name, static::$resolvedNonRelationMethods[static::class]) + ) { + return null; + } + } - $throughKey = $throughKey ?: $throughInstance->getForeignKey(); + // Directly defined relation methods + if (method_exists($this, $name)) { + $method = new \ReflectionMethod($this, $name); + } elseif (isset($this->extensionData['methods'][$name])) { + $extension = $this->extensionData['methods'][$name]; + $object = $this->extensionData['extensions'][$extension]; + $method = new \ReflectionMethod($object, $name); + } elseif (isset($this->extensionData['dynamicMethods'][$name])) { + $method = new \ReflectionFunction($this->extensionData['dynamicMethods'][$name]->getClosure()); + } else { + if (!isset(static::$resolvedNonRelationMethods[static::class])) { + static::$resolvedNonRelationMethods[static::class] = [$name]; + } else { + static::$resolvedNonRelationMethods[static::class][] = $name; + } + return null; + } - $localKey = $localKey ?: $this->getKeyName(); + if (count($method->getAttributes(Relation::class))) { + $attribute = $method->getAttributes(Relation::class)[0]; + $type = $attribute->newInstance()->getType(); - $secondLocalKey = $secondLocalKey ?: $throughInstance->getKeyName(); + static::$resolvedRelationMethods[static::class][$name] = [ + 'type' => $type, + ]; - $instance = $this->newRelatedInstance($related); + return $type; + } - return new HasManyThrough($instance->newQuery(), $this, $throughInstance, $primaryKey, $throughKey, $localKey, $secondLocalKey, $relationName); - } + $returnType = $method->getReturnType(); - /** - * Define a has-one-through relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\HasOneThrough - */ - public function hasOneThrough($related, $through, $primaryKey = null, $throughKey = null, $localKey = null, $secondLocalKey = null, $relationName = null) - { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); + if (is_null($returnType)) { + if (!isset(static::$resolvedNonRelationMethods[static::class])) { + static::$resolvedNonRelationMethods[static::class] = [$name]; + } else { + static::$resolvedNonRelationMethods[static::class][] = $name; + } + return null; } - $throughInstance = new $through; + if ( + $returnType instanceof \ReflectionNamedType + && in_array($returnType->getName(), array_values(static::$relationTypes)) + ) { + $type = array_search($returnType->getName(), static::$relationTypes); + + static::$resolvedRelationMethods[static::class][$name] = [ + 'type' => $type, + ]; - $primaryKey = $primaryKey ?: $this->getForeignKey(); + return $type; + } - $throughKey = $throughKey ?: $throughInstance->getForeignKey(); + if (!isset(static::$resolvedNonRelationMethods[static::class])) { + static::$resolvedNonRelationMethods[static::class] = [$name]; + } else { + static::$resolvedNonRelationMethods[static::class][] = $name; + } + return null; + } - $localKey = $localKey ?: $this->getKeyName(); + /** + * Determines if the provided method name is a relation method. + * + * A relation method either specifies the `Relation` attribute or has a return type that matches a relation. + */ + protected function isRelationMethod(string $name, bool $ignoreResolved = false): bool + { + if (!$this->methodExists($name)) { + return false; + } - $secondLocalKey = $secondLocalKey ?: $throughInstance->getKeyName(); + if (!$ignoreResolved) { + if (isset(static::$resolvedRelationMethods[static::class][$name])) { + return true; + } - $instance = $this->newRelatedInstance($related); + if ( + isset(static::$resolvedNonRelationMethods[static::class]) + && in_array($name, static::$resolvedNonRelationMethods[static::class]) + ) { + return false; + } + } - return new HasOneThrough($instance->newQuery(), $this, $throughInstance, $primaryKey, $throughKey, $localKey, $secondLocalKey, $relationName); + return $this->getRelationMethodType($name, $ignoreResolved) !== null; } /** - * Define a polymorphic one-to-many relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\MorphMany + * Generates a definition array for a relation method. */ - public function morphMany($related, $name, $type = null, $id = null, $localKey = null, $relationName = null) + protected function relationMethodDefinition(string $name, bool $ignoreResolved = false): array { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); + if (!$this->isRelationMethod($name)) { + return []; } - $instance = $this->newRelatedInstance($related); + if (!$ignoreResolved) { + if (isset(static::$resolvedRelationMethods[static::class][$name]['definition'])) { + return static::$resolvedRelationMethods[static::class][$name]['definition']; + } - list($type, $id) = $this->getMorphs($name, $type, $id); + if ( + isset(static::$resolvedNonRelationMethods[static::class]) + && in_array($name, static::$resolvedNonRelationMethods[static::class]) + ) { + return []; + } + } - $table = $instance->getTable(); + $definition = null; - $localKey = $localKey ?: $this->getKeyName(); + EloquentRelation::noConstraints(function () use ($name, &$definition) { + $definition = $this->{$name}()->getArrayDefinition() + $this->getRelationDefaults($this->getRelationType($name)); + }); - return new MorphMany($instance->newQuery(), $this, $table.'.'.$type, $table.'.'.$id, $localKey, $relationName); + return static::$resolvedRelationMethods[static::class][$name]['definition'] = $definition; } /** - * Define a many-to-many relationship. - * This code is almost a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\BelongsToMany + * {@inheritDoc} */ - public function belongsToMany($related, $table = null, $primaryKey = null, $foreignKey = null, $parentKey = null, $relatedKey = null, $relationName = null) + protected function newHasOne(Builder $query, Model $parent, $foreignKey, $localKey) { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); + $relation = new HasOne($query, $parent, $foreignKey, $localKey); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); } + return $relation; + } - $instance = $this->newRelatedInstance($related); - - $primaryKey = $primaryKey ?: $this->getForeignKey(); - - $foreignKey = $foreignKey ?: $instance->getForeignKey(); - - if (is_null($table)) { - $table = $this->joiningTable($related); + /** + * {@inheritDoc} + */ + protected function newHasOneThrough(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey) + { + $relation = new HasOneThrough($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); } - - return new BelongsToMany( - $instance->newQuery(), - $this, - $table, - $primaryKey, - $foreignKey, - $parentKey ?: $this->getKeyName(), - $relatedKey ?: $instance->getKeyName(), - $relationName - ); + return $relation; } /** - * Define a polymorphic many-to-many relationship. - * This code is almost a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\MorphToMany + * {@inheritDoc} */ - public function morphToMany($related, $name, $table = null, $primaryKey = null, $foreignKey = null, $parentKey = null, $relatedKey = null, $inverse = false, $relationName = null) + protected function newMorphOne(Builder $query, Model $parent, $type, $id, $localKey) { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); + $relation = new MorphOne($query, $parent, $type, $id, $localKey); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); } + return $relation; + } - $instance = $this->newRelatedInstance($related); - - $primaryKey = $primaryKey ?: $name.'_id'; + /** + * {@inheritDoc} + */ + public function guessBelongsToRelation() + { + return $this->getRelationCaller(); + } - $foreignKey = $foreignKey ?: $instance->getForeignKey(); + /** + * {@inheritDoc} + */ + public function guessBelongsToManyRelation() + { + return $this->getRelationCaller(); + } - $table = $table ?: Str::plural($name); + /** + * {@inheritDoc} + */ + protected function newBelongsTo(Builder $query, Model $child, $foreignKey, $ownerKey, $relation) + { + $relation = new BelongsTo($query, $child, $foreignKey, $ownerKey, $relation); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); + } + return $relation; + } - return new MorphToMany( - $instance->newQuery(), - $this, - $name, - $table, - $primaryKey, - $foreignKey, - $parentKey ?: $this->getKeyName(), - $relatedKey ?: $instance->getKeyName(), - $relationName, - $inverse - ); + /** + * {@inheritDoc} + */ + protected function newMorphTo(Builder $query, Model $parent, $foreignKey, $ownerKey, $type, $relation) + { + $relation = new MorphTo($query, $parent, $foreignKey, $ownerKey, $type, $relation); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); + } + return $relation; } /** - * Define a polymorphic many-to-many inverse relationship. - * This code is almost a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\MorphToMany + * {@inheritDoc} */ - public function morphedByMany($related, $name, $table = null, $primaryKey = null, $foreignKey = null, $parentKey = null, $relatedKey = null, $relationName = null) + protected function newHasMany(Builder $query, Model $parent, $foreignKey, $localKey) { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); + $relation = new HasMany($query, $parent, $foreignKey, $localKey); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); } + return $relation; + } - $primaryKey = $primaryKey ?: $this->getForeignKey(); + /** + * {@inheritDoc} + */ + protected function newHasManyThrough(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey) + { + $relation = new HasManyThrough($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); + } + return $relation; + } - $foreignKey = $foreignKey ?: $name.'_id'; + /** + * {@inheritDoc} + */ + protected function newMorphMany(Builder $query, Model $parent, $type, $id, $localKey) + { + $relation = new MorphMany($query, $parent, $type, $id, $localKey); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); + } + return $relation; + } - return $this->morphToMany( - $related, - $name, - $table, - $primaryKey, - $foreignKey, - $parentKey, - $relatedKey, - true, - $relationName - ); + /** + * {@inheritDoc} + */ + protected function newBelongsToMany( + Builder $query, + Model $parent, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName = null + ) { + $relation = new BelongsToMany($query, $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); + } + return $relation; } /** - * Define an attachment one-to-one relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\AttachOne + * {@inheritDoc} */ - public function attachOne($related, $isPublic = true, $localKey = null, $relationName = null) - { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); + protected function newMorphToMany( + Builder $query, + Model $parent, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName = null, + $inverse = false + ) { + $relation = new MorphToMany($query, $parent, $name, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName, $inverse); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); } + return $relation; + } + /** + * Define an attachment one-to-one (morphOne) relationship. + */ + public function attachOne($related, $isPublic = true, $localKey = null, $fieldName = null): AttachOne + { $instance = $this->newRelatedInstance($related); - list($type, $id) = $this->getMorphs('attachment', null, null); - $table = $instance->getTable(); $localKey = $localKey ?: $this->getKeyName(); - return new AttachOne($instance->newQuery(), $this, $table . '.' . $type, $table . '.' . $id, $isPublic, $localKey, $relationName); + $fieldName = $fieldName ?? $this->getRelationCaller(); + + $relation = new AttachOne($instance->newQuery(), $this, $table . '.attachment_type', $table . '.attachment_id', $isPublic, $localKey, $fieldName); + + // By default, attachments are dependent on primary models. + $relation->dependent(); + + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); + } + + return $relation; } /** - * Define an attachment one-to-many relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\AttachMany + * Define an attachment one-to-many (morphMany) relationship. */ - public function attachMany($related, $isPublic = null, $localKey = null, $relationName = null) + public function attachMany($related, $isPublic = null, $localKey = null, $fieldName = null): AttachMany { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); - } - $instance = $this->newRelatedInstance($related); - list($type, $id) = $this->getMorphs('attachment', null, null); - $table = $instance->getTable(); $localKey = $localKey ?: $this->getKeyName(); - return new AttachMany($instance->newQuery(), $this, $table . '.' . $type, $table . '.' . $id, $isPublic, $localKey, $relationName); - } + $fieldName = $fieldName ?? $this->getRelationCaller(); - /** - * Finds the calling function name from the stack trace. - */ - protected function getRelationCaller() - { - $backtrace = debug_backtrace(0); - $caller = ($backtrace[2]['function'] == 'handleRelation') ? $backtrace[4] : $backtrace[2]; - return $caller['function']; - } + $relation = new AttachMany($instance->newQuery(), $this, $table . '.attachment_type', $table . '.attachment_id', $isPublic, $localKey, $fieldName); - /** - * Returns a relation key value(s), not as an object. - */ - public function getRelationValue($relationName) - { - return $this->$relationName()->getSimpleValue(); - } + // By default, attachments are dependent on primary models. + $relation->dependent(); - /** - * Sets a relation value directly from its attribute. - */ - protected function setRelationValue($relationName, $value) - { - $this->$relationName()->setSimpleValue($value); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); + } + + return $relation; } /** @@ -802,7 +1126,7 @@ protected function setRelationValue($relationName, $value) */ protected function addRelation(string $type, string $name, array $config): void { - if (!in_array($type, static::$relationTypes)) { + if (!in_array($type, array_keys(static::$relationTypes))) { throw new InvalidArgumentException( sprintf( 'Cannot add the "%s" relation to %s, %s is not a valid relationship type.', @@ -955,17 +1279,4 @@ public function addHasManyThroughRelation(string $name, array $config): void { $this->addRelation('hasManyThrough', $name, $config); } - - /** - * Get the polymorphic relationship columns. - * - * @param string $name - * @param string|null $type - * @param string|null $id - * @return array - */ - protected function getMorphs($name, $type = null, $id = null) - { - return [$type ?: $name.'_type', $id ?: $name.'_id']; - } } diff --git a/src/Database/Model.php b/src/Database/Model.php index 8dfcc3822..81dbd59ca 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -770,7 +770,7 @@ public function __call($name, $params) * Never call handleRelation() anywhere else as it could * break getRelationCaller(), use $this->{$name}() instead */ - if ($this->hasRelation($name)) { + if ($this->hasRelation($name, true)) { return $this->handleRelation($name); } @@ -849,12 +849,9 @@ public function newPivot(EloquentModel $parent, array $attributes, $table, $exis */ public function newRelationPivot($relationName, $parent, $attributes, $table, $exists) { - $definition = $this->getRelationDefinition($relationName); - - if (!is_null($definition) && array_key_exists('pivotModel', $definition)) { - $pivotModel = $definition['pivotModel']; - return $pivotModel::fromRawAttributes($parent, $attributes, $table, $exists); - } + $relation = $this->{$relationName}(); + $pivotModel = $relation->getPivotClass(); + return $pivotModel::fromRawAttributes($parent, $attributes, $table, $exists); } // @@ -992,50 +989,7 @@ protected function performDeleteOnModel() { $this->performDeleteOnRelations(); - $this->setKeysForSaveQuery($this->newQueryWithoutScopes())->delete(); - } - - /** - * Locates relations with delete flag and cascades the delete event. - * For pivot relations, detach the pivot record unless the detach flag is false. - * @return void - */ - protected function performDeleteOnRelations() - { - $definitions = $this->getRelationDefinitions(); - foreach ($definitions as $type => $relations) { - /* - * Hard 'delete' definition - */ - foreach ($relations as $name => $options) { - if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { - // we want to remove the pivot record, not the actual relation record - if (Arr::get($options, 'detach', true)) { - $this->{$name}()->detach(); - } - } elseif (in_array($type, ['belongsTo', 'hasOneThrough', 'hasManyThrough', 'morphTo'])) { - // the model does not own the related record, we should not remove it. - continue; - } elseif (in_array($type, ['attachOne', 'attachMany', 'hasOne', 'hasMany', 'morphOne', 'morphMany'])) { - if (!Arr::get($options, 'delete', false)) { - continue; - } - - // Attempt to load the related record(s) - if (!$relation = $this->{$name}) { - continue; - } - - if ($relation instanceof EloquentModel) { - $relation->forceDelete(); - } elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { - $model->forceDelete(); - }); - } - } - } - } + parent::performDeleteOnModel(); } // diff --git a/src/Database/Relations/AttachMany.php b/src/Database/Relations/AttachMany.php index 06e0005b1..310dbfb62 100644 --- a/src/Database/Relations/AttachMany.php +++ b/src/Database/Relations/AttachMany.php @@ -1,4 +1,6 @@ -relationName = $relationName; - - $this->public = $isPublic; - + $this->fieldName = $fieldName; parent::__construct($query, $parent, $type, $id, $localKey); - - $this->addDefinedConstraints(); + $this->public = $isPublic; + $this->extendableRelationConstruct(); } /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { /* * Newly uploaded file(s) @@ -72,10 +78,9 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ - public function getSimpleValue() + public function getSimpleValue(): mixed { $value = null; @@ -128,4 +133,19 @@ protected function getSimpleValueInternal() return $value; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return [ + get_class($this->query->getModel()), + 'key' => $this->localKey, + 'delete' => $this->isDependent(), + 'public' => $this->public, + 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), + ]; + } } diff --git a/src/Database/Relations/AttachOne.php b/src/Database/Relations/AttachOne.php index 33a676b72..05f4fa7ef 100644 --- a/src/Database/Relations/AttachOne.php +++ b/src/Database/Relations/AttachOne.php @@ -1,4 +1,6 @@ -relationName = $relationName; - - $this->public = $isPublic; - + $this->fieldName = $fieldName; parent::__construct($query, $parent, $type, $id, $localKey); - - $this->addDefinedConstraints(); + $this->public = $isPublic; + $this->extendableRelationConstruct(); } /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { if (is_array($value)) { $value = reset($value); @@ -69,8 +75,7 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ public function getSimpleValue() { @@ -110,4 +115,19 @@ protected function getSimpleValueInternal() return $value; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return [ + get_class($this->query->getModel()), + 'key' => $this->localKey, + 'delete' => $this->isDependent(), + 'public' => $this->public, + 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), + ]; + } } diff --git a/src/Database/Relations/BelongsTo.php b/src/Database/Relations/BelongsTo.php index f708d56a2..d4bb7a38c 100644 --- a/src/Database/Relations/BelongsTo.php +++ b/src/Database/Relations/BelongsTo.php @@ -1,31 +1,32 @@ -relationName = $relationName; - parent::__construct($query, $child, $foreignKey, $ownerKey, $relationName); - - $this->addDefinedConstraints(); + $this->extendableRelationConstruct(); } /** @@ -53,10 +54,9 @@ public function remove(Model $model, $sessionKey = null) } /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { // Nulling the relationship if (!$value) { @@ -83,8 +83,7 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ public function getSimpleValue() { @@ -99,4 +98,18 @@ public function getOtherKey() { return $this->ownerKey; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return [ + get_class($this->query->getModel()), + 'key' => $this->getForeignKeyName(), + 'otherKey' => $this->getOtherKey(), + 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), + ]; + } } diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index ec140847c..fe5d053ee 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -1,10 +1,139 @@ -extendableRelationConstruct(); + } + + /** + * {@inheritDoc} + */ + public function getPivotClass() + { + return !empty($this->using) + ? $this->using + : Pivot::class; + } + + /** + * {@inheritDoc} + */ + public function setSimpleValue($value): void + { + $relationModel = $this->getRelated(); + + // Nulling the relationship + if (!$value) { + // Disassociate in memory immediately + $this->parent->setRelation($this->relationName, $relationModel->newCollection()); + + // Perform sync when the model is saved + $this->parent->bindEventOnce('model.afterSave', function () { + $this->detach(); + }); + return; + } + + // Convert models to keys + if ($value instanceof Model) { + $value = $value->getKey(); + } elseif (is_array($value)) { + foreach ($value as $_key => $_value) { + if ($_value instanceof Model) { + $value[$_key] = $_value->getKey(); + } + } + } + + // Convert scalar to array + if (!is_array($value) && !$value instanceof Collection) { + $value = [$value]; + } + + // Setting the relationship + $relationCollection = $value instanceof Collection + ? $value + : $relationModel->whereIn($relationModel->getKeyName(), $value)->get(); + + // Associate in memory immediately + $this->parent->setRelation($this->relationName, $relationCollection); + + // Perform sync when the model is saved + $this->parent->bindEventOnce('model.afterSave', function () use ($value) { + $this->sync($value); + }); + } + + /** + * {@inheritDoc} + */ + public function getSimpleValue() + { + $value = []; + + $sessionKey = $this->parent->sessionKey; + + if ($this->parent->relationLoaded($this->relationName)) { + $related = $this->getRelated(); + + $value = $this->parent->getRelation($this->relationName)->pluck($related->getKeyName())->all(); + } else { + $value = $this->allRelatedIds($sessionKey)->all(); + } + + return $value; + } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + $definition = [ + get_class($this->getRelated()), + 'table' => $this->getTable(), + 'key' => $this->getForeignPivotKeyName(), + 'otherKey' => $this->getRelatedKeyName(), + 'push' => $this->isPushable(), + 'detach' => $this->isDetachable(), + 'count' => $this->isCountOnly(), + ]; + + if (count($this->pivotColumns)) { + $definition['pivot'] = $this->pivotColumns; + } + + return $definition; + } } diff --git a/src/Database/Relations/Concerns/AttachOneOrMany.php b/src/Database/Relations/Concerns/AttachOneOrMany.php index 760cd1d6b..758465d92 100644 --- a/src/Database/Relations/Concerns/AttachOneOrMany.php +++ b/src/Database/Relations/Concerns/AttachOneOrMany.php @@ -12,14 +12,14 @@ trait AttachOneOrMany use DeferOneOrMany; /** - * @var string The "name" of the relationship. + * @var ?boolean Default value for file public or protected state. */ - protected $relationName; + protected $public; /** - * @var ?boolean Default value for file public or protected state. + * The field name (relation) to associate this attachment with. */ - protected $public; + protected string $fieldName; /** * Determines if the file should be flagged "public" or not. @@ -44,12 +44,20 @@ public function addConstraints() $this->query->where($this->foreignKey, '=', $this->getParentKey()); - $this->query->where('field', $this->relationName); + $this->query->where('field', $this->getFieldName()); $this->query->whereNotNull($this->foreignKey); } } + /** + * Get the field name (relation) to associate this attachment with. + */ + public function getFieldName() + { + return $this->fieldName; + } + /** * Add the constraints for a relationship count query. * @@ -71,7 +79,7 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $query = $query->where($this->morphType, $this->morphClass); - return $query->where('field', $this->relationName); + return $query->where('field', $this->fieldName); } /** @@ -105,7 +113,7 @@ public function addEagerConstraints(array $models) { parent::addEagerConstraints($models); - $this->query->where('field', $this->relationName); + $this->query->where('field', $this->fieldName); } /** @@ -122,7 +130,7 @@ public function save(Model $model, $sessionKey = null) $model->setAttribute('is_public', $this->isPublic()); } - $model->setAttribute('field', $this->relationName); + $model->setAttribute('field', $this->fieldName); if ($sessionKey === null) { return parent::save($model); @@ -146,7 +154,7 @@ public function create(array $attributes = [], $sessionKey = null) $attributes = array_merge(['is_public' => $this->isPublic()], $attributes); } - $attributes['field'] = $this->relationName; + $attributes['field'] = $this->fieldName; $model = parent::create($attributes); @@ -189,7 +197,7 @@ public function add(Model $model, $sessionKey = null) $model->setAttribute($this->getForeignKeyName(), $this->parent->getKey()); $model->setAttribute($this->getMorphType(), $this->morphClass); - $model->setAttribute('field', $this->relationName); + $model->setAttribute('field', $this->fieldName); $model->save(); /* diff --git a/src/Database/Relations/Concerns/BelongsOrMorphsToMany.php b/src/Database/Relations/Concerns/BelongsOrMorphsToMany.php index d45d8e64e..5ee571b02 100644 --- a/src/Database/Relations/Concerns/BelongsOrMorphsToMany.php +++ b/src/Database/Relations/Concerns/BelongsOrMorphsToMany.php @@ -1,4 +1,6 @@ -addDefinedConstraints(); - } - /** * Get the select columns for the relation query. * @@ -326,89 +293,6 @@ public function newPivot(array $attributes = [], $exists = false) return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey); } - /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; - */ - public function setSimpleValue($value) - { - $relationModel = $this->getRelated(); - - /* - * Nulling the relationship - */ - if (!$value) { - // Disassociate in memory immediately - $this->parent->setRelation($this->relationName, $relationModel->newCollection()); - - // Perform sync when the model is saved - $this->parent->bindEventOnce('model.afterSave', function () { - $this->detach(); - }); - return; - } - - /* - * Convert models to keys - */ - if ($value instanceof Model) { - $value = $value->getKey(); - } - elseif (is_array($value)) { - foreach ($value as $_key => $_value) { - if ($_value instanceof Model) { - $value[$_key] = $_value->getKey(); - } - } - } - - /* - * Convert scalar to array - */ - if (!is_array($value) && !$value instanceof Collection) { - $value = [$value]; - } - - /* - * Setting the relationship - */ - $relationCollection = $value instanceof Collection - ? $value - : $relationModel->whereIn($relationModel->getKeyName(), $value)->get(); - - // Associate in memory immediately - $this->parent->setRelation($this->relationName, $relationCollection); - - // Perform sync when the model is saved - $this->parent->bindEventOnce('model.afterSave', function () use ($value) { - $this->sync($value); - }); - } - - /** - * Helper for getting this relationship simple value, - * generally useful with form values. - */ - public function getSimpleValue() - { - $value = []; - - $relationName = $this->relationName; - - $sessionKey = $this->parent->sessionKey; - - if ($this->parent->relationLoaded($relationName)) { - $related = $this->getRelated(); - - $value = $this->parent->getRelation($relationName)->pluck($related->getKeyName())->all(); - } - else { - $value = $this->allRelatedIds($sessionKey)->all(); - } - - return $value; - } - /** * Get all of the IDs for the related models, with deferred binding support * diff --git a/src/Database/Relations/Concerns/CanBeCounted.php b/src/Database/Relations/Concerns/CanBeCounted.php new file mode 100644 index 000000000..9839af316 --- /dev/null +++ b/src/Database/Relations/Concerns/CanBeCounted.php @@ -0,0 +1,96 @@ +countOnly()` to + * the relationship definition method. For example: + * + * ```php + * public function totalMessages() + * { + * return $this->hasMany(Message::class)->countOnly(); + * } + * ``` + * + * If you are using the array-style definition, you can use the `count` key to mark the relationship as a counter only. + * + * ```php + * public $hasMany = [ + * 'total_messages' => [Message::class, 'count' => true] + * ]; + * ``` + * + * @author Ben Thomson + * @copyright Winter CMS Maintainers + */ +trait CanBeCounted +{ + /** + * Is this relation a counter? + */ + protected bool $countOnly = false; + + /** + * Mark the relationship as a count-only relationship. + */ + public function countOnly(bool $enabled = true): static + { + if (!$enabled) { + $this->countOnly = false; + + return $this; + } + + $this->countOnly = true; + + if ($this instanceof BelongsToMany) { + $this->countMode = true; + } + + $foreignKey = ($this instanceof HasOneThrough || $this instanceof HasManyThrough) + ? $this->getQualifiedFirstKeyName() + : $this->getForeignKey(); + $parent = ($this instanceof HasOneThrough || $this instanceof HasManyThrough) + ? $this->farParent + : $this->parent; + + $countSql = $parent->getConnection()->raw('count(*) as count'); + + return $this + ->select($foreignKey, $countSql) + ->groupBy($foreignKey) + ->orderBy($foreignKey); + } + + /** + * Mark the relationship as a full relationship. + */ + public function notCountOnly(): static + { + $this->countOnly = false; + + return $this; + } + + /** + * Determine if the relationship is only a counter. + */ + public function isCountOnly(): bool + { + return $this->countOnly; + } +} diff --git a/src/Database/Relations/Concerns/CanBeDependent.php b/src/Database/Relations/Concerns/CanBeDependent.php new file mode 100644 index 000000000..f8eec4595 --- /dev/null +++ b/src/Database/Relations/Concerns/CanBeDependent.php @@ -0,0 +1,73 @@ +dependent()` to the + * relationship definition method. For example: + * + * ```php + * public function messages() + * { + * return $this->hasMany(Message::class)->dependent(); + * } + * ``` + * + * If you are using the array-style definition, you can use the `delete` key to mark the relationship as dependent. + * + * ```php + * public $hasMany = [ + * 'messages' => [Message::class, 'delete' => true] + * ]; + * ``` + * + * @author Ben Thomson + * @copyright Winter CMS Maintainers + */ +trait CanBeDependent +{ + /** + * Is this relation dependent on the primary model? + */ + protected bool $dependent = false; + + /** + * Mark the relationship as dependent on the primary model. + */ + public function dependent(bool $enabled = true): static + { + $this->dependent = $enabled; + + return $this; + } + + /** + * Mark the relationship as independent of the primary model. + */ + public function notDependent(): static + { + $this->dependent = false; + + return $this; + } + + /** + * Determine if the relationship is dependent on the primary model. + */ + public function isDependent(): bool + { + return $this->dependent; + } +} diff --git a/src/Database/Relations/Concerns/CanBeDetachable.php b/src/Database/Relations/Concerns/CanBeDetachable.php new file mode 100644 index 000000000..126c9b156 --- /dev/null +++ b/src/Database/Relations/Concerns/CanBeDetachable.php @@ -0,0 +1,71 @@ +detachable()` to the + * relationship definition method. For example: + * + * ```php + * public function users() + * { + * return $this->belongsToMany(User::class)->detachable(); + * } + * ``` + * + * If you are using the array-style definition, you can use the `detach` key to mark the relationship as detachable. + * + * ```php + * public $belongsToMany = [ + * 'users' => [User::class, 'detach' => true] + * ]; + * ``` + * + * @author Ben Thomson + * @copyright Winter CMS Maintainers + */ +trait CanBeDetachable +{ + /** + * Should this relation be detached when the primary model is deleted? + */ + protected bool $detachable = true; + + /** + * Allow this relationship to be detached when the primary model is deleted. + */ + public function detachable(bool $enabled = true): static + { + $this->detachable = $enabled; + + return $this; + } + + /** + * Disallow this relationship to be detached when the primary model is deleted. + */ + public function notDetachable(): static + { + $this->detachable = false; + + return $this; + } + + /** + * Determine if the relation should be detached when the primary model is deleted. + */ + public function isDetachable(): bool + { + return $this->detachable; + } +} diff --git a/src/Database/Relations/Concerns/CanBeExtended.php b/src/Database/Relations/Concerns/CanBeExtended.php new file mode 100644 index 000000000..e90582ca5 --- /dev/null +++ b/src/Database/Relations/Concerns/CanBeExtended.php @@ -0,0 +1,126 @@ + + * @copyright Winter CMS Maintainers + */ +trait CanBeExtended +{ + use ExtendableTrait; + + /** + * @var string|array|null Extensions implemented by this class. + */ + public $implement = null; + + /** + * Indicates if the extendable constructor has completed. + */ + protected bool $extendableConstructed = false; + + /** + * This stores any locally-scoped callbacks fired before the extendable constructor had completed. + */ + protected array $localCallbacks = []; + + /** + * Constructor + */ + public function extendableRelationConstruct() + { + $this->extendableConstruct(); + $this->extendableConstructed = true; + + if (count($this->localCallbacks)) { + foreach ($this->localCallbacks as $callback) { + $this->extendableAddLocalExtension($callback[0], $callback[1]); + } + } + } + + public function __get($name) + { + return $this->extendableGet($name); + } + + public function __set($name, $value) + { + $this->extendableSet($name, $value); + } + + public function __call($name, $params) + { + if ($name === 'extend') { + if (empty($params[0]) || !is_callable($params[0])) { + throw new \InvalidArgumentException('The extend() method requires a callback parameter or closure.'); + } + if ($params[0] instanceof \Closure) { + return $this->extendableAddLocalExtension($params[0], $params[1] ?? null); + } + return $this->extendableAddLocalExtension(\Closure::fromCallable($params[0]), $params[1] ?? false); + } + + return $this->extendableCall($name, $params); + } + + public static function __callStatic($name, $params) + { + if ($name === 'extend') { + if (empty($params[0])) { + throw new \InvalidArgumentException('The extend() method requires a callback parameter or closure.'); + } + static::extendableAddExtension($params[0], $params[1] ?? false, $params[2] ?? null); + return; + } + + return static::extendableCallStatic($name, $params); + } + + /** + * Extends the class using a closure. + * + * The closure will be provided a single parameter which is the instance of the extended class, by default. + * + * You may optionally specify the callback as a scoped callback, which inherits the scope of the extended class and + * provides access to protected and private methods and properties. This makes any call using `$this` act on the + * extended class, not the class providing the extension. + * + * If you use a scoped callback, you can provide the "outer" scope - or the scope of the class providing the extension, + * with the third parameter. The outer scope object will then be passed as the single parameter to the closure. + */ + public static function extendableAddExtension(callable $callback, bool $scoped = false, ?object $outerScope = null): void + { + static::extendableExtendCallback($callback, $scoped, $outerScope); + } + + /** + * Adds local extensibility to the current instance. + * + * This rebinds a given closure to the current instance, making it able to access protected and private methods. This + * makes any call using `$this` within the closure act on the extended class, not the class providing the extension. + * + * An outer scope may be provided by providing a second parameter, which will then be passed through to the closure + * as its first parameter. If this is not given, the current instance will be provided as the first parameter. + */ + protected function extendableAddLocalExtension(\Closure $callback, ?object $outerScope = null) + { + if (!$this->extendableConstructed) { + $this->localCallbacks[] = [$callback, $outerScope]; + return; + } + + return $callback->call($this, $outerScope ?? $this); + } +} diff --git a/src/Database/Relations/Concerns/CanBePushed.php b/src/Database/Relations/Concerns/CanBePushed.php new file mode 100644 index 000000000..a727cbd59 --- /dev/null +++ b/src/Database/Relations/Concerns/CanBePushed.php @@ -0,0 +1,69 @@ +push()` or + * `->noPush()` to the relationship definition method. For example: + * + * ```php + * public function messages() + * { + * return $this->hasMany(Message::class)->push(); + * } + * ``` + * + * If you are using the array-style definition, you can use the `push` key to mark the relationship as pushable or not. + * + * ```php + * public $hasMany = [ + * 'messages' => [Message::class, 'push' => true] + * ]; + * ``` + * + * Please note that by default, all relationships are pushable. + * + * @author Ben Thomson + * @copyright Winter CMS Maintainers + */ +trait CanBePushed +{ + /** + * Is this relation pushable? + */ + protected bool $isPushable = true; + + /** + * Allow this relationship to be saved when the `push()` method is used on the primary model. + */ + public function pushable(bool $enabled = true): static + { + $this->isPushable = $enabled; + + return $this; + } + + /** + * Disallow this relationship from being saved when the `push()` method is used on the primary model. + */ + public function notPushable(): static + { + $this->isPushable = false; + + return $this; + } + + /** + * Determine if the relationship is pushable. + */ + public function isPushable(): bool + { + return $this->isPushable; + } +} diff --git a/src/Database/Relations/Concerns/CanBeSoftDeleted.php b/src/Database/Relations/Concerns/CanBeSoftDeleted.php new file mode 100644 index 000000000..75786504e --- /dev/null +++ b/src/Database/Relations/Concerns/CanBeSoftDeleted.php @@ -0,0 +1,85 @@ +softDeletable()` + * to the relationship definition method. For example: + * + * ```php + * public function messages() + * { + * return $this->hasMany(Message::class)->softDeletable(); + * } + * ``` + * + * If you are using the array-style definition, you can use the `softDelete` key to mark the relationship as + * soft-deletable. + * + * ```php + * public $hasMany = [ + * 'messages' => [Message::class, 'softDelete' => true] + * ]; + * ``` + * + * Please note that the related model must import the `Winter\Storm\Database\Traits\SoftDelete` trait in order to be + * marked as soft-deletable. + * + * @author Ben Thomson + * @copyright Winter CMS Maintainers + */ +trait CanBeSoftDeleted +{ + /** + * Is this relation dependent on the primary model? + */ + protected bool $isSoftDeletable = false; + + /** + * Defines the column that stores the "deleted at" timestamp. + */ + protected string $deletedAtColumn = 'deleted_at'; + + /** + * Mark the relationship as soft deletable. + */ + public function softDeletable(bool $enabled = true): static + { + if (in_array('Winter\Storm\Database\Traits\SoftDelete', class_uses_recursive($this->related))) { + $this->isSoftDeletable = $enabled; + } + + return $this; + } + + /** + * Mark the relationship as not soft deletable (will be hard-deleted instead if the `dependent` option is set). + */ + public function notSoftDeletable(): static + { + $this->isSoftDeletable = false; + + return $this; + } + + /** + * Determine if the related model is soft-deleted when the primary model is deleted. + */ + public function isSoftDeletable(): bool + { + return $this->isSoftDeletable; + } + + /** + * Gets the column that stores the "deleted at" timestamp. + */ + public function getDeletedAtColumn(): string + { + return $this->deletedAtColumn; + } +} diff --git a/src/Database/Relations/Concerns/DefinedConstraints.php b/src/Database/Relations/Concerns/DefinedConstraints.php index 36f0df845..1fa460076 100644 --- a/src/Database/Relations/Concerns/DefinedConstraints.php +++ b/src/Database/Relations/Concerns/DefinedConstraints.php @@ -1,6 +1,9 @@ -farParent)) { - // hasOneThrough / hasManyThrough relations - $parent = $this->farParent; - } else { - $parent = $this->parent; - } - $args = $parent->getRelationDefinition($this->relationName); + $args = ($this instanceof HasOneThrough || $this instanceof HasManyThrough) + ? $this->farParent->getRelationDefinition($this->relationName) + : $this->parent->getRelationDefinition($this->relationName); $this->addDefinedConstraintsToRelation($this, $args); - $this->addDefinedConstraintsToQuery($this, $args); } @@ -34,10 +34,13 @@ public function addDefinedConstraints() * @param \Illuminate\Database\Eloquent\Relations\Relation $relation * @param array|null $args */ - public function addDefinedConstraintsToRelation($relation, ?array $args = null) + public function addDefinedConstraintsToRelation($relation = null, ?array $args = null) { - if ($args === null) { - $args = $this->parent->getRelationDefinition($this->relationName); + if (is_null($relation)) { + $relation = $this; + } + if (is_null($args)) { + $args = $this->getRelationArgs(); } /* @@ -60,26 +63,6 @@ public function addDefinedConstraintsToRelation($relation, ?array $args = null) if (array_get($args, 'timestamps')) { $relation->withTimestamps(); } - - /* - * Count "helper" relation - */ - if ($count = array_get($args, 'count')) { - if ($relation instanceof BelongsToManyBase) { - $relation->countMode = true; - } - if (isset($relation->farParent)) { - $foreignKey = $relation->getQualifiedFirstKeyName(); - } else { - $foreignKey = $relation->getForeignKey(); - } - $countSql = $this->parent->getConnection()->raw('count(*) as count'); - $relation - ->select($foreignKey, $countSql) - ->groupBy($foreignKey) - ->orderBy($foreignKey) - ; - } } /** @@ -88,10 +71,13 @@ public function addDefinedConstraintsToRelation($relation, ?array $args = null) * @param \Illuminate\Database\Eloquent\Relations\Relation|\Winter\Storm\Database\QueryBuilder $query * @param array|null $args */ - public function addDefinedConstraintsToQuery($query, ?array $args = null) + public function addDefinedConstraintsToQuery($query = null, ?array $args = null) { - if ($args === null) { - $args = $this->parent->getRelationDefinition($this->relationName); + if (is_null($query)) { + $query = $this; + } + if (is_null($args)) { + $args = $this->getRelationArgs(); } /* @@ -130,4 +116,14 @@ public function addDefinedConstraintsToQuery($query, ?array $args = null) $query->$scope($this->parent); } } + + /** + * Get the relation definition for the related model. + */ + protected function getRelationArgs(): array + { + return ($this instanceof HasOneThrough || $this instanceof HasManyThrough) + ? $this->farParent->getRelationDefinition($this->relationName) + : $this->parent->getRelationDefinition($this->relationName); + } } diff --git a/src/Database/Relations/Concerns/HasOneOrMany.php b/src/Database/Relations/Concerns/HasOneOrMany.php index b6fe905b1..f8af52484 100644 --- a/src/Database/Relations/Concerns/HasOneOrMany.php +++ b/src/Database/Relations/Concerns/HasOneOrMany.php @@ -7,11 +7,6 @@ trait HasOneOrMany { use DeferOneOrMany; - /** - * @var string The "name" of the relationship. - */ - protected $relationName; - /** * Save the supplied related model with deferred binding support. */ diff --git a/src/Database/Relations/Concerns/HasRelationName.php b/src/Database/Relations/Concerns/HasRelationName.php new file mode 100644 index 000000000..bfc3161c0 --- /dev/null +++ b/src/Database/Relations/Concerns/HasRelationName.php @@ -0,0 +1,39 @@ + + * @copyright Winter CMS Maintainers. + */ +trait HasRelationName +{ + /** + * @var string The name of the relation. + */ + protected $relationName; + + /** + * Sets the name of the relation. + */ + public function setRelationName(string $name): void + { + $this->relationName = $name; + } + + /** + * Gets the relation name. + */ + public function getRelationName(): string + { + return $this->relationName; + } +} diff --git a/src/Database/Relations/HasMany.php b/src/Database/Relations/HasMany.php index 6db531455..57a47dea0 100644 --- a/src/Database/Relations/HasMany.php +++ b/src/Database/Relations/HasMany.php @@ -1,37 +1,40 @@ -relationName = $relationName; - parent::__construct($query, $parent, $foreignKey, $localKey); - - $this->addDefinedConstraints(); + $this->extendableRelationConstruct(); } /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { // Nulling the relationship if (!$value) { @@ -75,8 +78,7 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ public function getSimpleValue() { @@ -89,4 +91,19 @@ public function getSimpleValue() return $value; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return [ + get_class($this->query->getModel()), + 'key' => $this->getForeignKeyName(), + 'otherKey' => $this->getOtherKey(), + 'delete' => $this->isDependent(), + 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), + ]; + } } diff --git a/src/Database/Relations/HasManyThrough.php b/src/Database/Relations/HasManyThrough.php index 85c975d45..270608f92 100644 --- a/src/Database/Relations/HasManyThrough.php +++ b/src/Database/Relations/HasManyThrough.php @@ -1,7 +1,9 @@ -relationName = $relationName; - - parent::__construct($query, $farParent, $parent, $firstKey, $secondKey, $localKey, $secondLocalKey); - - $this->addDefinedConstraints(); + parent::__construct($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + $this->extendableRelationConstruct(); } /** @@ -42,4 +40,21 @@ public function parentSoftDeletes() return in_array('Winter\Storm\Database\Traits\SoftDelete', $uses) || in_array('Illuminate\Database\Eloquent\SoftDeletes', $uses); } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return [ + get_class($this->query->getModel()), + 'through' => get_class($this->throughParent), + 'key' => $this->getForeignKeyName(), + 'throughKey' => $this->getFirstKeyName(), + 'otherKey' => $this->getLocalKeyName(), + 'secondOtherKey' => $this->getSecondLocalKeyName(), + 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), + ]; + } } diff --git a/src/Database/Relations/HasOne.php b/src/Database/Relations/HasOne.php index d45e93825..16d0c5fb2 100644 --- a/src/Database/Relations/HasOne.php +++ b/src/Database/Relations/HasOne.php @@ -1,35 +1,38 @@ -relationName = $relationName; - parent::__construct($query, $parent, $foreignKey, $localKey); - - $this->addDefinedConstraints(); + $this->extendableRelationConstruct(); } /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { if (is_array($value)) { return; @@ -75,8 +78,7 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ public function getSimpleValue() { @@ -90,4 +92,19 @@ public function getSimpleValue() return $value; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return [ + get_class($this->query->getModel()), + 'key' => $this->getForeignKeyName(), + 'otherKey' => $this->getOtherKey(), + 'delete' => $this->isDependent(), + 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), + ]; + } } diff --git a/src/Database/Relations/HasOneThrough.php b/src/Database/Relations/HasOneThrough.php index efcfcfab4..ab0e8d260 100644 --- a/src/Database/Relations/HasOneThrough.php +++ b/src/Database/Relations/HasOneThrough.php @@ -1,7 +1,9 @@ -relationName = $relationName; - - parent::__construct($query, $farParent, $parent, $firstKey, $secondKey, $localKey, $secondLocalKey); - - $this->addDefinedConstraints(); + parent::__construct($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + $this->extendableRelationConstruct(); } /** @@ -42,4 +39,21 @@ public function parentSoftDeletes() return in_array('Winter\Storm\Database\Traits\SoftDelete', $uses) || in_array('Illuminate\Database\Eloquent\SoftDeletes', $uses); } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return [ + get_class($this->query->getModel()), + 'through' => get_class($this->throughParent), + 'key' => $this->getForeignKeyName(), + 'throughKey' => $this->getFirstKeyName(), + 'otherKey' => $this->getLocalKeyName(), + 'secondOtherKey' => $this->getSecondLocalKeyName(), + 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), + ]; + } } diff --git a/src/Database/Relations/MorphMany.php b/src/Database/Relations/MorphMany.php index dca6564d9..74375ba06 100644 --- a/src/Database/Relations/MorphMany.php +++ b/src/Database/Relations/MorphMany.php @@ -1,37 +1,47 @@ -relationName = $relationName; - parent::__construct($query, $parent, $type, $id, $localKey); - - $this->addDefinedConstraints(); + $this->extendableRelationConstruct(); } /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { // Nulling the relationship if (!$value) { @@ -83,8 +93,7 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ public function getSimpleValue() { @@ -97,4 +106,20 @@ public function getSimpleValue() return $value; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + + return [ + get_class($this->query->getModel()), + 'type' => $this->getMorphType(), + 'id' => $this->getForeignKeyName(), + 'delete' => $this->isDependent(), + 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), + ]; + } } diff --git a/src/Database/Relations/MorphOne.php b/src/Database/Relations/MorphOne.php index 42bbabfaf..9827174b3 100644 --- a/src/Database/Relations/MorphOne.php +++ b/src/Database/Relations/MorphOne.php @@ -1,35 +1,45 @@ -relationName = $relationName; - parent::__construct($query, $parent, $type, $id, $localKey); - - $this->addDefinedConstraints(); + $this->extendableRelationConstruct(); } /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { if (is_array($value)) { return; @@ -86,8 +96,7 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ public function getSimpleValue() { @@ -101,4 +110,19 @@ public function getSimpleValue() return $value; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return [ + get_class($this->query->getModel()), + 'type' => $this->getMorphType(), + 'id' => $this->getForeignKeyName(), + 'delete' => $this->isDependent(), + 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), + ]; + } } diff --git a/src/Database/Relations/MorphTo.php b/src/Database/Relations/MorphTo.php index 229c70efb..2ddc5b72c 100644 --- a/src/Database/Relations/MorphTo.php +++ b/src/Database/Relations/MorphTo.php @@ -1,37 +1,37 @@ -relationName = $relationName; - - parent::__construct($query, $parent, $foreignKey, $otherKey, $type, $relationName); - - $this->addDefinedConstraints(); + parent::__construct($query, $parent, $foreignKey, $ownerKey, $type, $relation); + $this->extendableRelationConstruct(); } /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { // Nulling the relationship if (!$value) { @@ -65,8 +65,7 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ public function getSimpleValue() { @@ -75,4 +74,18 @@ public function getSimpleValue() $this->parent->getAttribute($this->morphType) ]; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return [ + get_class($this->query->getModel()), + 'key' => $this->getForeignKeyName(), + 'otherKey' => $this->getOwnerKeyName(), + 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), + ]; + } } diff --git a/src/Database/Relations/MorphToMany.php b/src/Database/Relations/MorphToMany.php index 2487139c4..65de77982 100644 --- a/src/Database/Relations/MorphToMany.php +++ b/src/Database/Relations/MorphToMany.php @@ -1,8 +1,11 @@ -addDefinedConstraints(); + parent::__construct($query, $parent, $name, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName, $inverse); + $this->extendableRelationConstruct(); } /** @@ -91,4 +77,110 @@ public function newPivot(array $attributes = [], $exists = false) return $pivot; } + + /** + * {@inheritDoc} + */ + public function setSimpleValue($value): void + { + $relationModel = $this->getRelated(); + + /* + * Nulling the relationship + */ + if (!$value) { + // Disassociate in memory immediately + $this->parent->setRelation($this->relationName, $relationModel->newCollection()); + + // Perform sync when the model is saved + $this->parent->bindEventOnce('model.afterSave', function () { + $this->detach(); + }); + return; + } + + /* + * Convert models to keys + */ + if ($value instanceof Model) { + $value = $value->getKey(); + } + elseif (is_array($value)) { + foreach ($value as $_key => $_value) { + if ($_value instanceof Model) { + $value[$_key] = $_value->getKey(); + } + } + } + + /* + * Convert scalar to array + */ + if (!is_array($value) && !$value instanceof Collection) { + $value = [$value]; + } + + /* + * Setting the relationship + */ + $relationCollection = $value instanceof Collection + ? $value + : $relationModel->whereIn($relationModel->getKeyName(), $value)->get(); + + // Associate in memory immediately + $this->parent->setRelation($this->relationName, $relationCollection); + + // Perform sync when the model is saved + $this->parent->bindEventOnce('model.afterSave', function () use ($value) { + $this->sync($value); + }); + } + + /** + * {@inheritDoc} + */ + public function getSimpleValue() + { + $value = []; + + $relationName = $this->relationName; + + $sessionKey = $this->parent->sessionKey; + + if ($this->parent->relationLoaded($relationName)) { + $related = $this->getRelated(); + + $value = $this->parent->getRelation($relationName)->pluck($related->getKeyName())->all(); + } + else { + $value = $this->allRelatedIds($sessionKey)->all(); + } + + return $value; + } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + $definition = [ + get_class($this->query->getModel()), + 'table' => $this->getTable(), + 'key' => $this->getForeignPivotKeyName(), + 'otherKey' => $this->getRelatedPivotKeyName(), + 'parentKey' => $this->getParentKeyName(), + 'relatedKey' => $this->getRelatedKeyName(), + 'inverse' => $this->getInverse(), + 'push' => $this->isPushable(), + 'detach' => $this->isDetachable(), + 'count' => $this->isCountOnly(), + ]; + + if (count($this->pivotColumns)) { + $definition['pivot'] = $this->pivotColumns; + } + + return $definition; + } } diff --git a/src/Database/Relations/Relation.php b/src/Database/Relations/Relation.php index a8c9913a7..a1dcf3c34 100644 --- a/src/Database/Relations/Relation.php +++ b/src/Database/Relations/Relation.php @@ -1,16 +1,40 @@ - 'App\Post', - * 'videos' => 'App\Video', - * ]); + * Relations in Winter CMS must be able to set and get a simple value, which is a single value that represents the + * relation in a simple data format - for example, a string, integer or an array. It should (generally) not be + * returned as an object. * + * Retrieving this value will allow Winter to display or use the relation in forms, JavaScript, error messages and + * other contexts. + * + * When setting the value, the relation should be able to use or parse this value and convert into the appropriate + * relation data within Laravel's architecture. + * + * @author Ben Thomson + * @copyright Winter CMS Maintainers */ -abstract class Relation extends RelationBase +interface Relation { + /** + * Gets the simple representation of the relation. + * + * Retrieving this value will allow Winter to display or use the relation in forms, JavaScript, error messages + * and other contexts. + */ + public function getSimpleValue(); + + /** + * Creates or modifies the relation using its simple value format. + */ + public function setSimpleValue($value): void; + + /** + * Returns the relation definition in a simple array format. + */ + public function getArrayDefinition(): array; } diff --git a/src/Database/Traits/SoftDelete.php b/src/Database/Traits/SoftDelete.php index 82953eb2b..5e372a0ca 100644 --- a/src/Database/Traits/SoftDelete.php +++ b/src/Database/Traits/SoftDelete.php @@ -1,12 +1,19 @@ -getRelationDefinitions(); - foreach ($definitions as $type => $relations) { - foreach ($relations as $name => $options) { - if (!array_get($options, 'softDelete', false)) { - continue; - } - // Attempt to load the related record(s) - if (!$relation = $this->{$name}) { - continue; - } - if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { - // relations using pivot table - $value = $this->fromDateTime($this->freshTimestamp()); - $this->updatePivotDeletedAtColumn($name, $options, $value); - } elseif (in_array($type, ['belongsTo', 'hasOneThrough', 'hasManyThrough', 'morphTo'])) { - // the model does not own the related record, we should not remove it. - continue; - } elseif (in_array($type, ['attachOne', 'attachMany', 'hasOne', 'hasMany', 'morphOne', 'morphMany'])) { - if ($relation instanceof EloquentModel) { - $relation->delete(); - } elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { - $model->delete(); - }); - } - } + foreach ($this->getDefinedRelations() as $name => $relation) { + if (!$relation->methodExists('isSoftDeletable')) { + continue; + } + + // Apply soft delete to the relation if it's defined in the array config + $definition = $this->getRelationDefinition($name); + if (array_get($definition, 'softDelete', false)) { + $relation->softDeletable($definition['deletedAtColumn'] ?? 'deleted_at'); + } + + if (!$relation->isSoftDeletable()) { + continue; + } + + if (in_array(get_class($relation), [BelongsToMany::class, MorphToMany::class])) { + // relations using pivot table + $value = $this->fromDateTime($this->freshTimestamp()); + $this->updatePivotDeletedAtColumn($relation, $value); + return; + } + + $records = $relation->getResults(); + + if ($records instanceof EloquentModel) { + $records->delete(); + } elseif ($records instanceof CollectionBase) { + $records->each(function ($model) { + $model->delete(); + }); } } } @@ -187,13 +198,10 @@ public function restore() /** * Update relation pivot table deleted_at column */ - protected function updatePivotDeletedAtColumn(string $relationName, array $options, string|null $value) + protected function updatePivotDeletedAtColumn(Relation $relation, $value) { - // get deletedAtColumn from the relation options, otherwise use default - $deletedAtColumn = array_get($options, 'deletedAtColumn', 'deleted_at'); - - $this->{$relationName}()->newPivotQuery()->update([ - $deletedAtColumn => $value, + $relation->newPivotQuery()->update([ + $relation->getDeletedAtColumn() => $value, ]); } @@ -204,30 +212,37 @@ protected function updatePivotDeletedAtColumn(string $relationName, array $optio */ protected function performRestoreOnRelations() { - $definitions = $this->getRelationDefinitions(); - foreach ($definitions as $type => $relations) { - foreach ($relations as $name => $options) { - if (!array_get($options, 'softDelete', false)) { - continue; - } - - if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { - // relations using pivot table - $this->updatePivotDeletedAtColumn($name, $options, null); - } else { - $relation = $this->{$name}()->onlyTrashed()->getResults(); - if (!$relation) { - continue; - } - - if ($relation instanceof EloquentModel) { - $relation->restore(); - } elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { - $model->restore(); - }); - } - } + foreach ($this->getDefinedRelations() as $name => $relation) { + if (!$relation->methodExists('isSoftDeletable')) { + continue; + } + + // Apply soft delete to the relation if it's defined in the array config + $definition = $this->getRelationDefinition($name); + if (array_get($definition, 'softDelete', false)) { + $relation->softDeletable($definition['deletedAtColumn'] ?? 'deleted_at'); + } + + if (!$relation->isSoftDeletable()) { + continue; + } + + if (in_array(get_class($relation), [BelongsToMany::class, MorphToMany::class])) { + $this->updatePivotDeletedAtColumn($relation, null); + return; + } + + $results = $relation->onlyTrashed()->getResults(); + if (!$results) { + continue; + } + + if ($results instanceof EloquentModel) { + $results->restore(); + } elseif ($results instanceof CollectionBase) { + $results->each(function ($model) { + $model->restore(); + }); } } } diff --git a/src/Support/aliases.php b/src/Support/aliases.php index 91342c2b7..7622f3e6b 100644 --- a/src/Support/aliases.php +++ b/src/Support/aliases.php @@ -155,7 +155,6 @@ class_alias(\Winter\Storm\Database\Relations\MorphOne::class, \October\Rain\Data class_alias(\Winter\Storm\Database\Relations\Concerns\MorphOneOrMany::class, \October\Rain\Database\Relations\MorphOneOrMany::class); class_alias(\Winter\Storm\Database\Relations\MorphTo::class, \October\Rain\Database\Relations\MorphTo::class); class_alias(\Winter\Storm\Database\Relations\MorphToMany::class, \October\Rain\Database\Relations\MorphToMany::class); -class_alias(\Winter\Storm\Database\Relations\Relation::class, \October\Rain\Database\Relations\Relation::class); class_alias(\Winter\Storm\Database\Schema\Blueprint::class, \October\Rain\Database\Schema\Blueprint::class); class_alias(\Winter\Storm\Database\SortableScope::class, \October\Rain\Database\SortableScope::class); class_alias(\Winter\Storm\Database\Traits\DeferredBinding::class, \October\Rain\Database\Traits\DeferredBinding::class); diff --git a/tests/Database/Concerns/HasRelationshipsTest.php b/tests/Database/Concerns/HasRelationshipsTest.php index 6a622eebf..8e7fa9aef 100644 --- a/tests/Database/Concerns/HasRelationshipsTest.php +++ b/tests/Database/Concerns/HasRelationshipsTest.php @@ -1,76 +1,322 @@ assertEquals([], $model->getRelationTypeDefinitions('belongsToMany')); - $this->assertEquals([ - 'relatedModel' => 'TestModelNoRelation', - 'anotherRelatedModel' => [ - 'TestModelNoRelation', - 'order' => 'name desc', - ], - ], $model->getRelationTypeDefinitions('belongsTo')); + $author = new Author(); + + // Array style + $this->assertTrue($author->hasRelation('user')); + $this->assertTrue($author->hasRelation('country')); + $this->assertTrue($author->hasRelation('posts')); + $this->assertTrue($author->hasRelation('phone')); + $this->assertTrue($author->hasRelation('roles')); + $this->assertTrue($author->hasRelation('event_log')); + $this->assertTrue($author->hasRelation('meta')); + $this->assertTrue($author->hasRelation('tags')); + + // Laravel style + $this->assertTrue($author->hasRelation('contactNumber')); + $this->assertTrue($author->hasRelation('messages')); + $this->assertTrue($author->hasRelation('scopes')); + $this->assertTrue($author->hasRelation('executiveAuthors')); + $this->assertTrue($author->hasRelation('info')); + $this->assertTrue($author->hasRelation('auditLogs')); + + $this->assertFalse($author->hasRelation('invalid')); } - public function testDynamicGetRelationTypeDefinitions() + public function testGetRelationType() { - TestModelBelongsTo::extend(function ($model) { - $model->belongsTo['dynamicRelatedModel'] = 'TestModelNoRelation'; - }); - $model = new TestModelBelongsTo(); - $this->assertEquals([ - 'relatedModel' => 'TestModelNoRelation', - 'anotherRelatedModel' => [ - 'TestModelNoRelation', - 'order' => 'name desc', - ], - 'dynamicRelatedModel' => 'TestModelNoRelation' - ], $model->getRelationTypeDefinitions('belongsTo')); + $author = new Author(); + + // Array style + $this->assertEquals('belongsTo', $author->getRelationType('user')); + $this->assertEquals('belongsTo', $author->getRelationType('country')); + $this->assertEquals('hasMany', $author->getRelationType('posts')); + $this->assertEquals('hasOne', $author->getRelationType('phone')); + $this->assertEquals('belongsToMany', $author->getRelationType('roles')); + $this->assertEquals('morphMany', $author->getRelationType('event_log')); + $this->assertEquals('morphOne', $author->getRelationType('meta')); + $this->assertEquals('morphToMany', $author->getRelationType('tags')); + + // Laravel style + $this->assertEquals('hasOne', $author->getRelationType('contactNumber')); + $this->assertEquals('hasMany', $author->getRelationType('messages')); + $this->assertEquals('belongsToMany', $author->getRelationType('scopes')); + $this->assertEquals('belongsToMany', $author->getRelationType('executiveAuthors')); + $this->assertEquals('morphOne', $author->getRelationType('info')); + $this->assertEquals('morphMany', $author->getRelationType('auditLogs')); + + $this->assertNull($author->getRelationType('invalid')); } - public function testGetRelationTypeDefinition() + public function testGetDefinedRelations() { - $model = new TestModelBelongsTo(); - $this->assertEquals(null, $model->getRelationTypeDefinition('belongsTo', 'nonExistantRelation')); - $this->assertEquals('TestModelNoRelation', $model->getRelationTypeDefinition('belongsTo', 'relatedModel')); - $this->assertEquals(['TestModelNoRelation', 'order' => 'name desc'], $model->getRelationTypeDefinition('belongsTo', 'anotherRelatedModel')); - $this->assertEquals(null, $model->getRelationTypeDefinition('belongsToMany', 'nonExistantRelation')); - $this->assertEquals(null, $model->getRelationTypeDefinition('belongsToMany', 'relatedModel')); + $author = new Author(); + $defined = $author->getDefinedRelations(); + + $this->assertCount(16, $defined); + foreach ([ + 'user', + 'country', + 'user_soft', + 'posts', + 'phone', + 'roles', + 'event_log', + 'meta', + 'tags', + 'contactNumber', + 'messages', + 'scopes', + 'executiveAuthors', + 'info', + 'labels', + 'auditLogs', + ] as $expected) { + $this->assertArrayHasKey($expected, $defined); + } + + $this->assertInstanceOf(BelongsTo::class, $defined['user']); + $this->assertInstanceOf(BelongsTo::class, $defined['country']); + $this->assertInstanceOf(BelongsTo::class, $defined['user_soft']); + $this->assertInstanceOf(HasMany::class, $defined['posts']); + $this->assertInstanceOf(HasOne::class, $defined['phone']); + $this->assertInstanceOf(BelongsToMany::class, $defined['roles']); + $this->assertInstanceOf(MorphMany::class, $defined['event_log']); + $this->assertInstanceOf(MorphOne::class, $defined['meta']); + $this->assertInstanceOf(MorphToMany::class, $defined['tags']); + $this->assertInstanceOf(HasOne::class, $defined['contactNumber']); + $this->assertInstanceOf(HasMany::class, $defined['messages']); + $this->assertInstanceOf(BelongsToMany::class, $defined['scopes']); + $this->assertInstanceOf(BelongsToMany::class, $defined['executiveAuthors']); + $this->assertInstanceOf(MorphOne::class, $defined['info']); + $this->assertInstanceOf(MorphToMany::class, $defined['labels']); + $this->assertInstanceOf(MorphMany::class, $defined['auditLogs']); } - public function testDynamicGetRelationTypeDefinition() + public function testGetRelationDefinition() { - TestModelBelongsTo::extend(function ($model) { - $model->belongsTo['dynamicRelatedModel'] = 'TestModelNoRelation'; - }); - $model = new TestModelBelongsTo(); - $this->assertEquals('TestModelNoRelation', $model->getRelationTypeDefinition('belongsTo', 'dynamicRelatedModel')); + $author = new Author(); + + // Array style + $this->assertEquals([User::class, 'delete' => true], $author->getRelationDefinition('user')); + $this->assertEquals([Country::class], $author->getRelationDefinition('country')); + $this->assertEquals([Post::class], $author->getRelationDefinition('posts')); + $this->assertEquals([Phone::class], $author->getRelationDefinition('phone')); + $this->assertEquals([ + Role::class, + 'table' => 'database_tester_authors_roles' + ], $author->getRelationDefinition('roles')); + $this->assertEquals([EventLog::class, 'name' => 'related', 'delete' => true, 'softDelete' => true], $author->getRelationDefinition('event_log')); + $this->assertEquals([Meta::class, 'name' => 'taggable'], $author->getRelationDefinition('meta')); + $this->assertEquals([ + Tag::class, + 'name' => 'taggable', + 'table' => 'database_tester_taggables', + 'pivot' => ['added_by'] + ], $author->getRelationDefinition('tags')); + + // Laravel style + $this->assertEquals([ + Phone::class, + 'key' => 'author_id', + 'otherKey' => 'id', + 'delete' => false, + 'push' => true, + 'count' => false, + ], $author->getRelationDefinition('contactNumber')); + $this->assertEquals([ + Post::class, + 'key' => 'author_id', + 'otherKey' => 'id', + 'delete' => false, + 'push' => true, + 'count' => false, + ], $author->getRelationDefinition('messages')); + $this->assertEquals([ + Role::class, + 'table' => 'database_tester_authors_roles', + 'key' => 'author_id', + 'otherKey' => 'id', + 'push' => true, + 'detach' => true, + 'count' => false, + ], $author->getRelationDefinition('scopes')); + $this->assertEquals([ + Meta::class, + 'type' => 'taggable_type', + 'id' => 'taggable_id', + 'delete' => false, + 'push' => true, + 'count' => false, + ], $author->getRelationDefinition('info')); + $this->assertEquals([ + EventLog::class, + 'type' => 'related_type', + 'id' => 'related_id', + 'delete' => true, + 'push' => true, + 'count' => false, + ], $author->getRelationDefinition('auditLogs')); + $this->assertEquals([ + Tag::class, + 'table' => 'database_tester_taggables', + 'key' => 'taggable_id', + 'otherKey' => 'tag_id', + 'parentKey' => 'id', + 'relatedKey' => 'id', + 'inverse' => false, + 'push' => true, + 'count' => false, + 'pivot' => ['added_by'], + 'detach' => true, + ], $author->getRelationDefinition('labels')); + + $this->assertNull($author->getRelationDefinition('invalid')); } -} -/* - * Class with belongsTo relation - */ -class TestModelBelongsTo extends Model -{ - public $belongsTo = [ - 'relatedModel' => 'TestModelNoRelation', - 'anotherRelatedModel' => [ - 'TestModelNoRelation', - 'order' => 'name desc', - ] - ]; -} + public function testGetRelationDefinitions() + { + $author = new Author(); + $definitions = $author->getRelationDefinitions(); -/* - * Class with no belongsTo relation - */ -class TestModelNoRelation extends Model -{ + $this->assertCount(3, $definitions['belongsTo']); + $this->assertEquals([ + 'user' => [User::class, 'delete' => true], + 'country' => [Country::class], + 'user_soft' => [SoftDeleteUser::class, 'key' => 'user_id', 'softDelete' => true], + ], $definitions['belongsTo']); + + $this->assertCount(2, $definitions['hasMany']); + + $this->assertEquals([ + 'posts' => [Post::class], // Property-style + 'messages' => [ // Laravel-style + Post::class, + 'key' => 'author_id', + 'otherKey' => 'id', + 'delete' => false, + 'push' => true, + 'count' => false + ], + ], $definitions['hasMany']); + + $this->assertCount(2, $definitions['hasOne']); + + $this->assertEquals([ + 'phone' => [Phone::class], // Property-style + 'contactNumber' => [ // Laravel-style + Phone::class, + 'key' => 'author_id', + 'otherKey' => 'id', + 'delete' => false, + 'push' => true, + 'count' => false + ], + ], $definitions['hasOne']); + + $this->assertCount(3, $definitions['belongsToMany']); + + $this->assertEquals([ + 'roles' => [ // Property-style + 'Winter\Storm\Tests\Database\Fixtures\Role', + 'table' => 'database_tester_authors_roles' + ], + 'scopes' => [ // Laravel-style + Role::class, + 'table' => 'database_tester_authors_roles', + 'key' => 'author_id', + 'otherKey' => 'id', + 'push' => true, + 'detach' => true, + 'count' => false, + ], + 'executiveAuthors' => [ // Laravel-style + Role::class, + 'table' => 'database_tester_authors_roles', + 'key' => 'author_id', + 'otherKey' => 'id', + 'push' => true, + 'detach' => true, + 'count' => false, + ], + ], $definitions['belongsToMany']); + + $this->assertCount(2, $definitions['morphMany']); + + $this->assertEquals([ + 'event_log' => [EventLog::class, 'name' => 'related', 'delete' => true, 'softDelete' => true], // Property-style + 'auditLogs' => [ // Laravel-style + EventLog::class, + 'type' => 'related_type', + 'id' => 'related_id', + 'delete' => true, + 'push' => true, + 'count' => false, + ], + ], $definitions['morphMany']); + + $this->assertCount(2, $definitions['morphOne']); + + $this->assertEquals([ + 'meta' => [Meta::class, 'name' => 'taggable'], // Property-style + 'info' => [ // Laravel-style + Meta::class, + 'type' => 'taggable_type', + 'id' => 'taggable_id', + 'delete' => false, + 'push' => true, + 'count' => false, + ], + ], $definitions['morphOne']); + + $this->assertCount(2, $definitions['morphToMany']); + + $this->assertEquals([ + 'tags' => [ // Property-style + Tag::class, + 'name' => 'taggable', + 'table' => 'database_tester_taggables', + 'pivot' => ['added_by'] + ], + 'labels' => [ // Laravel-style + Tag::class, + 'table' => 'database_tester_taggables', + 'key' => 'taggable_id', + 'otherKey' => 'tag_id', + 'parentKey' => 'id', + 'relatedKey' => 'id', + 'inverse' => false, + 'push' => true, + 'count' => false, + 'pivot' => ['added_by'], + 'detach' => true, + ], + ], $definitions['morphToMany']); + } } diff --git a/tests/Database/Fixtures/Author.php b/tests/Database/Fixtures/Author.php new file mode 100644 index 000000000..448921898 --- /dev/null +++ b/tests/Database/Fixtures/Author.php @@ -0,0 +1,133 @@ + [User::class, 'delete' => true], + 'country' => Country::class, + 'user_soft' => [SoftDeleteUser::class, 'key' => 'user_id', 'softDelete' => true], + ]; + + public $hasMany = [ + 'posts' => Post::class, + ]; + + public $hasOne = [ + 'phone' => Phone::class, + ]; + + public $belongsToMany = [ + 'roles' => [ + 'Winter\Storm\Tests\Database\Fixtures\Role', + 'table' => 'database_tester_authors_roles' + ], + ]; + + public $morphMany = [ + 'event_log' => [EventLog::class, 'name' => 'related', 'delete' => true, 'softDelete' => true], + ]; + + public $morphOne = [ + 'meta' => [Meta::class, 'name' => 'taggable'], + ]; + + public $morphToMany = [ + 'tags' => [ + Tag::class, + 'name' => 'taggable', + 'table' => 'database_tester_taggables', + 'pivot' => ['added_by'] + ], + ]; + + public function contactNumber(): HasOne + { + return $this->hasOne(Phone::class); + } + + public function messages(): HasMany + { + return $this->hasMany(Post::class); + } + + #[Relation('belongsToMany')] + public function scopes() + { + return $this->belongsToMany(Role::class, 'database_tester_authors_roles'); + } + + #[Relation(BelongsToMany::class)] + public function executiveAuthors() + { + return $this->belongsToMany(Role::class, 'database_tester_authors_roles')->wherePivot('is_executive', 1); + } + + #[Relation('morphOne')] + public function info() + { + return $this->morphOne(Meta::class, 'taggable'); + } + + public function labels(): MorphToMany + { + return $this->morphToMany(Tag::class, 'taggable', 'database_tester_taggables')->withPivot('added_by'); + } + + #[Relation('morphMany')] + public function auditLogs() + { + return $this->morphMany(EventLog::class, 'related')->dependent(); + } + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_authors')) { + return; + } + + $builder->create('database_tester_authors', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->integer('user_id')->unsigned()->index()->nullable(); + $table->integer('country_id')->unsigned()->index()->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_authors')) { + return; + } + + $builder->dropIfExists('database_tester_authors'); + } +} diff --git a/tests/Database/Fixtures/Category.php b/tests/Database/Fixtures/Category.php new file mode 100644 index 000000000..aef5a00d3 --- /dev/null +++ b/tests/Database/Fixtures/Category.php @@ -0,0 +1,58 @@ + [ + Post::class, + 'table' => 'database_tester_categories_posts', + 'pivot' => ['category_name', 'post_name'] + ] + ]; + + public function getCustomNameAttribute() + { + return $this->name.' (#'.$this->id.')'; + } + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_categories')) { + return; + } + + $builder->create('database_tester_categories', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->integer('parent_id')->nullable(); + $table->string('name')->nullable(); + $table->string('slug')->nullable()->index()->unique(); + $table->string('description')->nullable(); + $table->integer('company_id')->unsigned()->nullable(); + $table->string('language', 3)->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_categories')) { + return; + } + + $builder->drop('database_tester_categories'); + } +} diff --git a/tests/Database/Fixtures/CategoryNested.php b/tests/Database/Fixtures/CategoryNested.php new file mode 100644 index 000000000..0a568201a --- /dev/null +++ b/tests/Database/Fixtures/CategoryNested.php @@ -0,0 +1,47 @@ +hasTable('database_tester_categories_nested')) { + return; + } + + $builder->create('database_tester_categories_nested', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->integer('parent_id')->nullable(); + $table->integer('nest_left')->nullable(); + $table->integer('nest_right')->nullable(); + $table->integer('nest_depth')->nullable(); + $table->string('name')->nullable(); + $table->string('slug')->nullable()->index()->unique(); + $table->string('description')->nullable(); + $table->integer('company_id')->unsigned()->nullable(); + $table->string('language', 3)->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_categories_nested')) { + return; + } + + $builder->dropIfExists('database_tester_categories_nested'); + } +} diff --git a/tests/Database/Fixtures/CategorySimple.php b/tests/Database/Fixtures/CategorySimple.php new file mode 100644 index 000000000..bad509796 --- /dev/null +++ b/tests/Database/Fixtures/CategorySimple.php @@ -0,0 +1,8 @@ + [ + User::class, + ], + ]; + + public $hasManyThrough = [ + 'posts' => [ + Post::class, + 'through' => Author::class, + ], + 'posts_count' => [ + Post::class, + 'through' => Author::class, + 'count' => true, + ] + ]; + + public function messages(): HasManyThrough + { + return $this->hasManyThrough(Post::class, Author::class); + } + + public function messagesCount(): HasManyThrough + { + return $this->hasManyThrough(Post::class, Author::class)->countOnly(); + } + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_countries')) { + return; + } + + $builder->create('database_tester_countries', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('name')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_countries')) { + return; + } + + $builder->dropIfExists('database_tester_countries'); + } +} diff --git a/tests/Database/Fixtures/DuplicateRelationNote.php b/tests/Database/Fixtures/DuplicateRelationNote.php new file mode 100644 index 000000000..eda7a8522 --- /dev/null +++ b/tests/Database/Fixtures/DuplicateRelationNote.php @@ -0,0 +1,56 @@ + [ + Author::class, + ] + ]; + + public function author(): BelongsTo + { + return $this->belongsTo(Author::class); + } + + /** + * @var string The database table used by the model. + */ + public $table = 'database_tester_notes'; + + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_notes')) { + return; + } + + $builder->create('database_tester_notes', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->integer('author_id')->nullable(); + $table->string('note')->nullable(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_notes')) { + return; + } + + $builder->dropIfExists('database_tester_notes'); + } +} diff --git a/tests/Database/Fixtures/EventLog.php b/tests/Database/Fixtures/EventLog.php new file mode 100644 index 000000000..bbe9173d6 --- /dev/null +++ b/tests/Database/Fixtures/EventLog.php @@ -0,0 +1,60 @@ + [] + ]; + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_event_log')) { + return; + } + + $builder->create('database_tester_event_log', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('action', 30)->nullable(); + $table->string('related_id')->index()->nullable(); + $table->string('related_type')->index()->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_event_log')) { + return; + } + + $builder->dropIfExists('database_tester_event_log'); + } +} diff --git a/tests/Database/Fixtures/Meta.php b/tests/Database/Fixtures/Meta.php new file mode 100644 index 000000000..d48d4bd79 --- /dev/null +++ b/tests/Database/Fixtures/Meta.php @@ -0,0 +1,59 @@ + [] + ]; + + public $fillable = [ + 'meta_title', + 'meta_description', + 'meta_keywords', + 'canonical_url', + 'redirect_url', + 'robot_index', + 'robot_follow' + ]; + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_meta')) { + return; + } + + $builder->create('database_tester_meta', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id')->unsigned(); + $table->integer('taggable_id')->unsigned()->index()->nullable(); + $table->string('taggable_type')->nullable(); + $table->string('meta_title')->nullable(); + $table->string('meta_description')->nullable(); + $table->string('meta_keywords')->nullable(); + $table->string('canonical_url')->nullable(); + $table->string('redirect_url')->nullable(); + $table->string('robot_index')->nullable(); + $table->string('robot_follow')->nullable(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_meta')) { + return; + } + + $builder->dropIfExists('database_tester_meta'); + } +} diff --git a/tests/Database/Fixtures/MigratesForTesting.php b/tests/Database/Fixtures/MigratesForTesting.php new file mode 100644 index 000000000..8bdcb8f4c --- /dev/null +++ b/tests/Database/Fixtures/MigratesForTesting.php @@ -0,0 +1,33 @@ + + * @copyright Winter CMS + */ +trait MigratesForTesting +{ + /** + * Store the models that have been migrated. + */ + public static $migrated = false; + + /** + * Migrate the schema up for the model. + */ + public static function migrateUp(Builder $builder): void + { + } + + /** + * Migrate the schema down for the model. + */ + public static function migrateDown(Builder $builder): void + { + } +} diff --git a/tests/Database/Fixtures/NullablePost.php b/tests/Database/Fixtures/NullablePost.php new file mode 100644 index 000000000..bd9a8b1e8 --- /dev/null +++ b/tests/Database/Fixtures/NullablePost.php @@ -0,0 +1,20 @@ + Author::class, + ]; + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_phones')) { + return; + } + + $builder->create('database_tester_phones', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('number')->nullable(); + $table->integer('author_id')->unsigned()->index()->nullable(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_phones')) { + return; + } + + $builder->dropIfExists('database_tester_phones'); + } +} diff --git a/tests/Database/Fixtures/Post.php b/tests/Database/Fixtures/Post.php new file mode 100644 index 000000000..9380b6f11 --- /dev/null +++ b/tests/Database/Fixtures/Post.php @@ -0,0 +1,111 @@ + Author::class, + ]; + + public $belongsToMany = [ + 'categories' => [ + Category::class, + 'table' => 'database_tester_categories_posts', + 'pivot' => ['category_name', 'post_name'] + ] + ]; + + public $morphMany = [ + 'event_log' => [EventLog::class, 'name' => 'related', 'delete' => true, 'softDelete' => true], + ]; + + public $morphOne = [ + 'meta' => [Meta::class, 'name' => 'taggable'], + ]; + + public $morphToMany = [ + 'tags' => [ + Tag::class, + 'name' => 'taggable', + 'table' => 'database_tester_taggables', + 'pivot' => ['added_by'] + ], + ]; + + public function writer(): BelongsTo + { + return $this->belongsTo(Author::class, 'author_id'); + } + + public function info(): MorphOne + { + return $this->morphOne(Meta::class, 'taggable'); + } + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_posts')) { + return; + } + + $builder->create('database_tester_posts', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('title')->nullable(); + $table->string('slug')->nullable()->index(); + $table->text('long_slug')->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_published')->default(false); + $table->timestamp('published_at')->nullable(); + $table->integer('author_id')->unsigned()->index()->nullable(); + $table->string('author_nickname')->default('Winter')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + + $builder->create('database_tester_categories_posts', function ($table) { + $table->engine = 'InnoDB'; + $table->integer('category_id')->unsigned(); + $table->integer('post_id')->unsigned(); + $table->primary(['category_id', 'post_id']); + $table->string('category_name')->nullable(); + $table->string('post_name')->nullable(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_posts')) { + return; + } + + $builder->dropIfExists('database_tester_categories_posts'); + $builder->dropIfExists('database_tester_posts'); + } +} diff --git a/tests/Database/Fixtures/RevisionablePost.php b/tests/Database/Fixtures/RevisionablePost.php new file mode 100644 index 000000000..2d0dc7056 --- /dev/null +++ b/tests/Database/Fixtures/RevisionablePost.php @@ -0,0 +1,53 @@ + [Revision::class, 'name' => 'revisionable'] + ]; + + /** + * The user who made the revision. + */ + public function getRevisionableUser() + { + return 7; + } +} diff --git a/tests/Database/Fixtures/Role.php b/tests/Database/Fixtures/Role.php new file mode 100644 index 000000000..dac39629e --- /dev/null +++ b/tests/Database/Fixtures/Role.php @@ -0,0 +1,79 @@ + [ + User::class, + 'table' => 'database_tester_authors_roles' + ], + ]; + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'database_tester_authors_roles'); + } + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_roles')) { + return; + } + + $builder->create('database_tester_roles', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + $builder->create('database_tester_authors_roles', function ($table) { + $table->engine = 'InnoDB'; + $table->integer('author_id')->unsigned(); + $table->integer('role_id')->unsigned(); + $table->primary(['author_id', 'role_id']); + $table->string('clearance_level')->nullable(); + $table->boolean('is_executive')->default(false); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_roles')) { + return; + } + + $builder->dropIfExists('database_tester_roles'); + $builder->dropIfExists('database_tester_authors_roles'); + } +} diff --git a/tests/Database/Fixtures/SluggablePost.php b/tests/Database/Fixtures/SluggablePost.php new file mode 100644 index 000000000..b3387b955 --- /dev/null +++ b/tests/Database/Fixtures/SluggablePost.php @@ -0,0 +1,21 @@ + 'title', + 'long_slug' => ['title', 'description'] + ]; +} diff --git a/tests/Database/Fixtures/SoftDeleteAuthor.php b/tests/Database/Fixtures/SoftDeleteAuthor.php new file mode 100644 index 000000000..23dbddc1b --- /dev/null +++ b/tests/Database/Fixtures/SoftDeleteAuthor.php @@ -0,0 +1,8 @@ + [ + Author::class, + 'name' => 'taggable', + 'table' => 'database_tester_taggables', + 'pivot' => ['added_by'], + ], + 'posts' => [ + Post::class, + 'name' => 'taggable', + 'table' => 'database_tester_taggables', + 'pivot' => ['added_by'], + ], + ]; + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_tags')) { + return; + } + + $builder->create('database_tester_tags', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + $builder->create('database_tester_taggables', function ($table) { + $table->engine = 'InnoDB'; + $table->unsignedInteger('tag_id'); + $table->morphs('taggable', 'testings_taggable'); + $table->unsignedInteger('added_by')->nullable(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_tags')) { + return; + } + + $builder->dropIfExists('database_tester_taggables'); + $builder->dropIfExists('database_tester_tags'); + } +} diff --git a/tests/Database/Fixtures/User.php b/tests/Database/Fixtures/User.php new file mode 100644 index 000000000..288042bc5 --- /dev/null +++ b/tests/Database/Fixtures/User.php @@ -0,0 +1,89 @@ + [ + Author::class, + ] + ]; + + public $hasOneThrough = [ + 'phone' => [ + Phone::class, + 'through' => Author::class, + ], + ]; + + public $attachOne = [ + 'avatar' => File::class, + ]; + + public $attachMany = [ + 'photos' => File::class, + ]; + + public function contactNumber(): HasOneThrough + { + return $this->hasOneThrough(Phone::class, Author::class); + } + + public function displayPicture(): AttachOne + { + return $this->attachOne(File::class, true, null, 'avatar'); + } + + public function images(): AttachMany + { + return $this->attachMany(File::class, true, null, 'photos'); + } + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_users')) { + return; + } + + $builder->create('database_tester_users', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_users')) { + return; + } + + $builder->dropIfExists('database_tester_users'); + } +} diff --git a/tests/Database/Fixtures/UserLaravel.php b/tests/Database/Fixtures/UserLaravel.php new file mode 100644 index 000000000..ffc7058b7 --- /dev/null +++ b/tests/Database/Fixtures/UserLaravel.php @@ -0,0 +1,52 @@ +hasOne(Author::class, 'user_id')->dependent(); + } + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_users')) { + return; + } + + $builder->create('database_tester_users', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_users')) { + return; + } + + $builder->dropIfExists('database_tester_users'); + } +} diff --git a/tests/Database/Fixtures/UserLaravelWithSoftAuthor.php b/tests/Database/Fixtures/UserLaravelWithSoftAuthor.php new file mode 100644 index 000000000..10d6a1426 --- /dev/null +++ b/tests/Database/Fixtures/UserLaravelWithSoftAuthor.php @@ -0,0 +1,13 @@ +hasOne(SoftDeleteAuthor::class, 'user_id')->softDeletable(); + } +} diff --git a/tests/Database/Fixtures/UserLaravelWithSoftAuthorAndSoftDelete.php b/tests/Database/Fixtures/UserLaravelWithSoftAuthorAndSoftDelete.php new file mode 100644 index 000000000..71e374513 --- /dev/null +++ b/tests/Database/Fixtures/UserLaravelWithSoftAuthorAndSoftDelete.php @@ -0,0 +1,8 @@ + [Author::class, 'key' => 'user_id', 'delete' => true], + ]; +} diff --git a/tests/Database/Fixtures/UserWithAuthorAndSoftDelete.php b/tests/Database/Fixtures/UserWithAuthorAndSoftDelete.php new file mode 100644 index 000000000..c333b61be --- /dev/null +++ b/tests/Database/Fixtures/UserWithAuthorAndSoftDelete.php @@ -0,0 +1,8 @@ + [SoftDeleteAuthor::class, 'key' => 'user_id', 'softDelete' => true], + ]; +} diff --git a/tests/Database/Fixtures/UserWithSoftAuthorAndSoftDelete.php b/tests/Database/Fixtures/UserWithSoftAuthorAndSoftDelete.php new file mode 100644 index 000000000..fcb2b413f --- /dev/null +++ b/tests/Database/Fixtures/UserWithSoftAuthorAndSoftDelete.php @@ -0,0 +1,8 @@ + 'required|min:3|max:255', + 'slug' => ['required', 'regex:/^[a-z0-9\/\:_\-\*\[\]\+\?\|]*$/i', 'unique:database_tester_posts'], + ]; +} diff --git a/tests/Database/Relations/AttachManyTest.php b/tests/Database/Relations/AttachManyTest.php new file mode 100644 index 000000000..ae3e75923 --- /dev/null +++ b/tests/Database/Relations/AttachManyTest.php @@ -0,0 +1,85 @@ + 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertEmpty($user->photos); + $user->photos()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotEmpty($user->photos); + + $photo = $user->photos->first(); + $photoId = $photo->id; + + $user->photos()->remove($photo); + $this->assertNull(File::find($photoId)); + } + + public function testDeleteFlagDestroyRelationshipLaravelRelation() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertEmpty($user->images); + $user->images()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotEmpty($user->images); + + $photo = $user->images->first(); + $photoId = $photo->id; + + $user->images()->remove($photo); + $this->assertNull(File::find($photoId)); + } + + public function testDeleteFlagDeleteModel() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertEmpty($user->photos); + $user->photos()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotEmpty($user->photos); + + $photo = $user->photos->first(); + $this->assertNotNull($photo); + $photoId = $photo->id; + + $user->delete(); + $this->assertNull(File::find($photoId)); + } + + public function testDeleteFlagDeleteModelLaravelRelation() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertEmpty($user->images); + $user->images()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotEmpty($user->images); + + $photo = $user->images->first(); + $this->assertNotNull($photo); + $photoId = $photo->id; + + $user->delete(); + $this->assertNull(File::find($photoId)); + } +} diff --git a/tests/Database/Relations/AttachOneTest.php b/tests/Database/Relations/AttachOneTest.php new file mode 100644 index 000000000..2ed5e47d0 --- /dev/null +++ b/tests/Database/Relations/AttachOneTest.php @@ -0,0 +1,179 @@ + 'Stevie', 'email' => 'stevie@example.com']); + $user2 = User::create(['name' => 'Joe', 'email' => 'joe@example.com']); + Model::reguard(); + + // Set by string + $user->avatar = dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png'; + + // @todo $user->avatar currently sits as a string, not good for validation + // this should really assert as an UploadedFile instead. + + // Commit the file and it should snap to a File model + $user->save(); + + $this->assertNotNull($user->avatar); + $this->assertEquals('avatar.png', $user->avatar->file_name); + + // Set by Uploaded file + $sample = $user->avatar; + $upload = new UploadedFile( + dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png', + $sample->file_name, + $sample->content_type, + null, + true + ); + + $user2->avatar = $upload; + + // The file is prepped but not yet commited, this is for validation + $this->assertNotNull($user2->avatar); + $this->assertEquals($upload, $user2->avatar); + + // Commit the file and it should snap to a File model + $user2->save(); + + $this->assertNotNull($user2->avatar); + $this->assertEquals('avatar.png', $user2->avatar->file_name); + } + + public function testSetRelationValueLaravelRelation() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $user2 = User::create(['name' => 'Joe', 'email' => 'joe@example.com']); + Model::reguard(); + + // Set by string + $user->displayPicture = dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png'; + + // @todo $user->displayPicture currently sits as a string, not good for validation + // this should really assert as an UploadedFile instead. + + // Commit the file and it should snap to a File model + $user->save(); + + $this->assertNotNull($user->displayPicture); + $this->assertEquals('avatar.png', $user->displayPicture->file_name); + + // Set by Uploaded file + $sample = $user->displayPicture; + $upload = new UploadedFile( + dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png', + $sample->file_name, + $sample->content_type, + null, + true + ); + + $user2->displayPicture = $upload; + + // The file is prepped but not yet commited, this is for validation + $this->assertNotNull($user2->displayPicture); + $this->assertEquals($upload, $user2->displayPicture); + + // Commit the file and it should snap to a File model + $user2->save(); + + $this->assertNotNull($user2->displayPicture); + $this->assertEquals('avatar.png', $user2->displayPicture->file_name); + } + + public function testDeleteFlagDestroyRelationship() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertNull($user->avatar); + $user->avatar()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotNull($user->avatar); + + $avatar = $user->avatar; + $avatarId = $avatar->id; + + $user->avatar()->remove($avatar); + $this->assertNull(File::find($avatarId)); + } + + public function testDeleteFlagDestroyRelationshipLaravelRelation() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertNull($user->displayPicture); + $user->displayPicture()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotNull($user->displayPicture); + + $avatar = $user->displayPicture; + $avatarId = $avatar->id; + + $user->displayPicture()->remove($avatar); + $this->assertNull(File::find($avatarId)); + } + + public function testDeleteFlagDeleteModel() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertNull($user->avatar); + $user->avatar()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotNull($user->avatar); + + $avatarId = $user->avatar->id; + $user->delete(); + $this->assertNull(File::find($avatarId)); + } + + public function testDeleteFlagDeleteModelLaravelRelation() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertNull($user->displayPicture); + $user->displayPicture()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotNull($user->displayPicture); + + $avatarId = $user->displayPicture->id; + $user->delete(); + $this->assertNull(File::find($avatarId)); + } + + public function testDeleteFlagSoftDeleteModel() + { + Model::unguard(); + $user = SoftDeleteUser::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $user->avatar()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $this->assertNotNull($user->avatar); + + $avatarId = $user->avatar->id; + $user->delete(); + $this->assertNotNull(File::find($avatarId)); + } +} diff --git a/tests/Database/Relations/BelongsToManyTest.php b/tests/Database/Relations/BelongsToManyTest.php new file mode 100644 index 000000000..f149f54ca --- /dev/null +++ b/tests/Database/Relations/BelongsToManyTest.php @@ -0,0 +1,372 @@ + 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + $role3 = Role::create(['name' => "Manager", 'description' => "Budget"]); + Model::reguard(); + + // Add/remove to collection + $this->assertFalse($author->roles->contains($role1->id)); + $author->roles()->add($role1); + $author->roles()->add($role2); + $this->assertTrue($author->roles->contains($role1->id)); + $this->assertTrue($author->roles->contains($role2->id)); + + // Set by Model object + $author->roles = $role1; + $this->assertEquals(1, $author->roles->count()); + $this->assertEquals('Designer', $author->roles->first()->name); + + $author->roles = [$role1, $role2, $role3]; + $this->assertEquals(3, $author->roles->count()); + + // Set by primary key + $author->roles = $role2->id; + $this->assertEquals(1, $author->roles->count()); + $this->assertEquals('Programmer', $author->roles->first()->name); + + $author->roles = [$role2->id, $role3->id]; + $this->assertEquals(2, $author->roles->count()); + + // Nullify + $author->roles = null; + $this->assertEquals(0, $author->roles->count()); + + // Extra nullify checks (still exists in DB until saved) + $author->reloadRelations('roles'); + $this->assertEquals(2, $author->roles->count()); + $author->save(); + $author->reloadRelations('roles'); + $this->assertEquals(0, $author->roles->count()); + + // Deferred in memory + $author->roles = [$role2->id, $role3->id]; + $this->assertEquals(2, $author->roles->count()); + $this->assertEquals('Programmer', $author->roles->first()->name); + } + + public function testSetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + $role3 = Role::create(['name' => "Manager", 'description' => "Budget"]); + Model::reguard(); + + // Add/remove to collection + $this->assertFalse($author->scopes->contains($role1->id)); + $author->scopes()->add($role1); + $author->scopes()->add($role2); + $this->assertTrue($author->scopes->contains($role1->id)); + $this->assertTrue($author->scopes->contains($role2->id)); + + // Set by Model object + $author->scopes = $role1; + $this->assertEquals(1, $author->scopes->count()); + $this->assertEquals('Designer', $author->scopes->first()->name); + + $author->scopes = [$role1, $role2, $role3]; + $this->assertEquals(3, $author->scopes->count()); + + // Set by primary key + $author->scopes = $role2->id; + $this->assertEquals(1, $author->scopes->count()); + $this->assertEquals('Programmer', $author->scopes->first()->name); + + $author->scopes = [$role2->id, $role3->id]; + $this->assertEquals(2, $author->scopes->count()); + + // Nullify + $author->scopes = null; + $this->assertEquals(0, $author->scopes->count()); + + // Extra nullify checks (still exists in DB until saved) + $author->reloadRelations('scopes'); + $this->assertEquals(2, $author->scopes->count()); + $author->save(); + $author->reloadRelations('scopes'); + $this->assertEquals(0, $author->scopes->count()); + + // Deferred in memory + $author->scopes = [$role2->id, $role3->id]; + $this->assertEquals(2, $author->scopes->count()); + $this->assertEquals('Programmer', $author->scopes->first()->name); + } + + public function testGetRelationValue() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + Model::reguard(); + + $author->roles()->add($role1); + $author->roles()->add($role2); + + $this->assertEquals([$role1->id, $role2->id], $author->getRelationValue('roles')); + } + + public function testGetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + Model::reguard(); + + $author->scopes()->add($role1); + $author->scopes()->add($role2); + + $this->assertEquals([$role1->id, $role2->id], $author->getRelationValue('scopes')); + } + + public function testDeferredBinding() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + + $category = Category::create(['name' => 'News']); + $post1 = Post::create(['title' => 'First post']); + $post2 = Post::create(['title' => 'Second post']); + Model::reguard(); + + // Deferred add + $author->roles()->add($role1, $sessionKey); + $author->roles()->add($role2, $sessionKey); + $category->posts()->add($post1, $sessionKey, [ + 'category_name' => $category->name . ' in pivot', + 'post_name' => $post1->title . ' in pivot', + ]); + $category->posts()->add($post2, $sessionKey, [ + 'category_name' => $category->name . ' in pivot', + 'post_name' => $post2->title . ' in pivot', + ]); + $this->assertEmpty($author->roles); + $this->assertEmpty($category->posts); + + $this->assertEquals(0, $author->roles()->count()); + $this->assertEquals(2, $author->roles()->withDeferred($sessionKey)->count()); + $this->assertEquals(0, $category->posts()->count()); + $this->assertEquals(2, $category->posts()->withDeferred($sessionKey)->count()); + + // Get simple value (implicit) + $author->reloadRelations(); + $author->sessionKey = $sessionKey; + $this->assertEquals([$role1->id, $role2->id], $author->getRelationValue('roles')); + $category->reloadRelations(); + $category->sessionKey = $sessionKey; + $this->assertEquals([$post1->id, $post2->id], $category->getRelationValue('posts')); + + // Get simple value (explicit) + $relatedIds = $author->roles()->allRelatedIds($sessionKey)->all(); + $this->assertEquals([$role1->id, $role2->id], $relatedIds); + $relatedIds = $category->posts()->allRelatedIds($sessionKey)->all(); + $this->assertEquals([$post1->id, $post2->id], $relatedIds); + + // Commit deferred + $author->save(null, $sessionKey); + $category->save(null, $sessionKey); + $this->assertEquals(2, $author->roles()->count()); + $this->assertEquals('Designer', $author->roles->first()->name); + $this->assertEquals(2, $category->posts()->count()); + $this->assertEquals('First post', $category->posts->first()->title); + $this->assertEquals('Second post', $category->posts->last()->title); + $this->assertEquals('First post in pivot', $category->posts->first()->pivot->post_name); + $this->assertEquals('Second post in pivot', $category->posts->last()->pivot->post_name); + $this->assertEquals('News in pivot', $category->posts->first()->pivot->category_name); + $this->assertEquals('News in pivot', $category->posts->last()->pivot->category_name); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->roles()->remove($role1, $sessionKey); + $author->roles()->remove($role2, $sessionKey); + $category->posts()->remove($post1, $sessionKey); + $category->posts()->remove($post2, $sessionKey); + $this->assertEquals(2, $author->roles()->count()); + $this->assertEquals(0, $author->roles()->withDeferred($sessionKey)->count()); + $this->assertEquals(2, $category->posts()->count()); + $this->assertEquals(0, $category->posts()->withDeferred($sessionKey)->count()); + $this->assertEquals('Designer', $author->roles->first()->name); + $this->assertEquals('First post', $category->posts->first()->title); + $this->assertEquals('Second post', $category->posts->last()->title); + $this->assertEquals('First post in pivot', $category->posts->first()->pivot->post_name); + $this->assertEquals('Second post in pivot', $category->posts->last()->pivot->post_name); + $this->assertEquals('News in pivot', $category->posts->first()->pivot->category_name); + $this->assertEquals('News in pivot', $category->posts->last()->pivot->category_name); + + // Commit deferred + $author->save(null, $sessionKey); + $category->save(null, $sessionKey); + $this->assertEquals(0, $author->roles()->count()); + $this->assertEquals(0, $author->roles->count()); + $this->assertEquals(0, $category->posts()->count()); + $this->assertEquals(0, $category->posts->count()); + } + + public function testDeferredBindingLaravelRelation() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + + $category = Category::create(['name' => 'News']); + $post1 = Post::create(['title' => 'First post']); + $post2 = Post::create(['title' => 'Second post']); + Model::reguard(); + + // Deferred add + $author->scopes()->add($role1, $sessionKey); + $author->scopes()->add($role2, $sessionKey); + $category->posts()->add($post1, $sessionKey, [ + 'category_name' => $category->name . ' in pivot', + 'post_name' => $post1->title . ' in pivot', + ]); + $category->posts()->add($post2, $sessionKey, [ + 'category_name' => $category->name . ' in pivot', + 'post_name' => $post2->title . ' in pivot', + ]); + $this->assertEmpty($author->scopes); + $this->assertEmpty($category->posts); + + $this->assertEquals(0, $author->scopes()->count()); + $this->assertEquals(2, $author->scopes()->withDeferred($sessionKey)->count()); + $this->assertEquals(0, $category->posts()->count()); + $this->assertEquals(2, $category->posts()->withDeferred($sessionKey)->count()); + + // Get simple value (implicit) + $author->reloadRelations(); + $author->sessionKey = $sessionKey; + $this->assertEquals([$role1->id, $role2->id], $author->getRelationValue('scopes')); + $category->reloadRelations(); + $category->sessionKey = $sessionKey; + $this->assertEquals([$post1->id, $post2->id], $category->getRelationValue('posts')); + + // Get simple value (explicit) + $relatedIds = $author->scopes()->allRelatedIds($sessionKey)->all(); + $this->assertEquals([$role1->id, $role2->id], $relatedIds); + $relatedIds = $category->posts()->allRelatedIds($sessionKey)->all(); + $this->assertEquals([$post1->id, $post2->id], $relatedIds); + + // Commit deferred + $author->save(null, $sessionKey); + $category->save(null, $sessionKey); + $this->assertEquals(2, $author->scopes()->count()); + $this->assertEquals('Designer', $author->scopes->first()->name); + $this->assertEquals(2, $category->posts()->count()); + $this->assertEquals('First post', $category->posts->first()->title); + $this->assertEquals('Second post', $category->posts->last()->title); + $this->assertEquals('First post in pivot', $category->posts->first()->pivot->post_name); + $this->assertEquals('Second post in pivot', $category->posts->last()->pivot->post_name); + $this->assertEquals('News in pivot', $category->posts->first()->pivot->category_name); + $this->assertEquals('News in pivot', $category->posts->last()->pivot->category_name); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->scopes()->remove($role1, $sessionKey); + $author->scopes()->remove($role2, $sessionKey); + $category->posts()->remove($post1, $sessionKey); + $category->posts()->remove($post2, $sessionKey); + $this->assertEquals(2, $author->scopes()->count()); + $this->assertEquals(0, $author->scopes()->withDeferred($sessionKey)->count()); + $this->assertEquals(2, $category->posts()->count()); + $this->assertEquals(0, $category->posts()->withDeferred($sessionKey)->count()); + $this->assertEquals('Designer', $author->scopes->first()->name); + $this->assertEquals('First post', $category->posts->first()->title); + $this->assertEquals('Second post', $category->posts->last()->title); + $this->assertEquals('First post in pivot', $category->posts->first()->pivot->post_name); + $this->assertEquals('Second post in pivot', $category->posts->last()->pivot->post_name); + $this->assertEquals('News in pivot', $category->posts->first()->pivot->category_name); + $this->assertEquals('News in pivot', $category->posts->last()->pivot->category_name); + + // Commit deferred + $author->save(null, $sessionKey); + $category->save(null, $sessionKey); + $this->assertEquals(0, $author->scopes()->count()); + $this->assertEquals(0, $author->scopes->count()); + $this->assertEquals(0, $category->posts()->count()); + $this->assertEquals(0, $category->posts->count()); + } + + public function testDetachAfterDelete() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + $role3 = Role::create(['name' => "Manager", 'description' => "Budget"]); + Model::reguard(); + + $author->roles()->add($role1); + $author->roles()->add($role2); + $author->roles()->add($role3); + $this->assertEquals(3, DB::table('database_tester_authors_roles')->where('author_id', $author->id)->count()); + + $author->delete(); + $this->assertEquals(0, DB::table('database_tester_authors_roles')->where('author_id', $author->id)->count()); + } + + public function testDetachAfterDeleteLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + $role3 = Role::create(['name' => "Manager", 'description' => "Budget"]); + Model::reguard(); + + $author->scopes()->add($role1); + $author->scopes()->add($role2); + $author->scopes()->add($role3); + $this->assertEquals(3, DB::table('database_tester_authors_roles')->where('author_id', $author->id)->count()); + + $author->delete(); + $this->assertEquals(0, DB::table('database_tester_authors_roles')->where('author_id', $author->id)->count()); + } + + public function testConditionsWithPivotAttributes() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + $role3 = Role::create(['name' => "Manager", 'description' => "Budget"]); + Model::reguard(); + + $author->roles()->add($role1, null, ['is_executive' => 1]); + $author->roles()->add($role2, null, ['is_executive' => 1]); + $author->roles()->add($role3, null, ['is_executive' => 0]); + + $this->assertEquals([1, 2], $author->executiveAuthors->lists('id')); + $this->assertEquals([1, 2], $author->executiveAuthors()->lists('id')); + $this->assertEquals([1, 2], $author->executiveAuthors()->get()->lists('id')); + } +} diff --git a/tests/Database/Relations/BelongsToTest.php b/tests/Database/Relations/BelongsToTest.php new file mode 100644 index 000000000..614eaa0b0 --- /dev/null +++ b/tests/Database/Relations/BelongsToTest.php @@ -0,0 +1,179 @@ + 'First post', 'description' => 'Yay!!']); + $author1 = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author2 = Author::create(['name' => 'Louie', 'email' => 'louie@example.com']); + $author3 = Author::make(['name' => 'Charlie', 'email' => 'charlie@example.com']); + Model::reguard(); + + // Set by Model object + $post->author = $author1; + $this->assertEquals($author1->id, $post->author_id); + $this->assertEquals('Stevie', $post->author->name); + + // Set by primary key + $post->author = $author2->id; + $this->assertEquals($author2->id, $post->author_id); + $this->assertEquals('Louie', $post->author->name); + + // Nullify + $post->author = null; + $this->assertNull($post->author_id); + $this->assertNull($post->author); + + // Deferred in memory + $post->author = $author3; + $this->assertEquals('Charlie', $post->author->name); + $this->assertNull($post->author_id); + $author3->save(); + $this->assertEquals($author3->id, $post->author_id); + } + + /** + * Tests a belongsTo relation being specified with the Laravel format - ie. a public method that returns a relation + * instance. + */ + public function testSetRelationValueLaravelRelation() + { + Model::unguard(); + $post = Post::create(['title' => 'First post', 'description' => 'Yay!!']); + $author1 = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author2 = Author::create(['name' => 'Louie', 'email' => 'louie@example.com']); + $author3 = Author::make(['name' => 'Charlie', 'email' => 'charlie@example.com']); + Model::reguard(); + + // Set by Model object + $post->writer = $author1; + $this->assertEquals($author1->id, $post->author_id); + $this->assertEquals('Stevie', $post->writer->name); + + // Set by primary key + $post->writer = $author2->id; + $this->assertEquals($author2->id, $post->author_id); + $this->assertEquals('Louie', $post->writer->name); + + // Nullify + $post->writer = null; + $this->assertNull($post->author_id); + $this->assertNull($post->writer); + + // Deferred in memory + $post->writer = $author3; + $this->assertEquals('Charlie', $post->writer->name); + $this->assertNull($post->author_id); + $author3->save(); + $this->assertEquals($author3->id, $post->author_id); + } + + public function testGetRelationValue() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $post = Post::make(['title' => "First post", 'author_id' => $author->id]); + Model::reguard(); + + $this->assertEquals($author->id, $post->getRelationValue('author')); + } + + public function testGetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $post = Post::make(['title' => "First post", 'author_id' => $author->id]); + Model::reguard(); + + $this->assertEquals($author->id, $post->getRelationValue('writer')); + } + + public function testDeferredBinding() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $post = Post::make(['title' => "First post"]); + $author = Author::create(['name' => 'Stevie']); + Model::reguard(); + + // Deferred add + $post->author()->add($author, $sessionKey); + $this->assertNull($post->author_id); + $this->assertNull($post->author); + + $this->assertEquals(0, $post->author()->count()); + $this->assertEquals(1, $post->author()->withDeferred($sessionKey)->count()); + + // Commit deferred + $post->save(null, $sessionKey); + $this->assertEquals(1, $post->author()->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals('Stevie', $post->author->name); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $post->author()->remove($author, $sessionKey); + $this->assertEquals(1, $post->author()->count()); + $this->assertEquals(0, $post->author()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals('Stevie', $post->author->name); + + // Commit deferred + $post->save(null, $sessionKey); + $this->assertEquals(0, $post->author()->count()); + $this->assertNull($post->author_id); + $this->assertNull($post->author); + } + + public function testDeferredBindingLaravelRelation() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $post = Post::make(['title' => "First post"]); + $author = Author::create(['name' => 'Stevie']); + Model::reguard(); + + // Deferred add + $post->writer()->add($author, $sessionKey); + $this->assertNull($post->author_id); + $this->assertNull($post->writer); + + $this->assertEquals(0, $post->writer()->count()); + $this->assertEquals(1, $post->writer()->withDeferred($sessionKey)->count()); + + // Commit deferred + $post->save(null, $sessionKey); + $this->assertEquals(1, $post->writer()->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals('Stevie', $post->writer->name); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $post->writer()->remove($author, $sessionKey); + $this->assertEquals(1, $post->writer()->count()); + $this->assertEquals(0, $post->writer()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals('Stevie', $post->writer->name); + + // Commit deferred + $post->save(null, $sessionKey); + $this->assertEquals(0, $post->writer()->count()); + $this->assertNull($post->author_id); + $this->assertNull($post->writer); + } +} diff --git a/tests/Database/Relations/DuplicateRelationTest.php b/tests/Database/Relations/DuplicateRelationTest.php new file mode 100644 index 000000000..106cddb55 --- /dev/null +++ b/tests/Database/Relations/DuplicateRelationTest.php @@ -0,0 +1,51 @@ + 'Stevie', 'email' => 'stevie@example.com']); + $note = DuplicateRelationNote::create(['note' => 'This is a note']); + Model::reguard(); + + $note->author()->associate($author); + $note->save(); + + $this->assertEquals($author->id, $note->author_id); + } + + public function testMethodPropertyWhenMethodRelationExists() + { + $this->expectException(SystemException::class); + $this->expectExceptionMessageMatches('/Relation "author" in model "' . preg_quote(DuplicateRelationNote::class) . '" is defined both/'); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $note = DuplicateRelationNote::create(['note' => 'This is a note']); + Model::reguard(); + + $note->author = $author; + $note->save(); + + $this->assertEquals($author->id, $note->author_id); + } + + public function testGetRelationDefinitionWhenBothExist() + { + $this->expectException(SystemException::class); + $this->expectExceptionMessageMatches('/Relation "author" in model "' . preg_quote(DuplicateRelationNote::class) . '" is defined both/'); + + $note = new DuplicateRelationNote; + $relation = $note->getRelationDefinition('author'); + } +} diff --git a/tests/Database/Relations/DynamicRelationTest.php b/tests/Database/Relations/DynamicRelationTest.php new file mode 100644 index 000000000..5989f3fbf --- /dev/null +++ b/tests/Database/Relations/DynamicRelationTest.php @@ -0,0 +1,47 @@ + 'First post', 'description' => 'Yay!!']); + $author1 = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author2 = Author::create(['name' => 'Louie', 'email' => 'louie@example.com']); + Model::reguard(); + + Post::extend(function (Post $model) { + $model->addDynamicMethod('creator', function () use ($model): BelongsTo { + return $model->belongsTo(Author::class, 'author_id'); + }); + }); + + $post = new Post; + + $this->assertTrue($post->hasRelation('creator')); + $this->assertEquals('belongsTo', $post->getRelationType('creator')); + + // Set by Model object + $post->creator = $author1; + $this->assertEquals($author1->id, $post->author_id); + $this->assertEquals('Stevie', $post->creator->name); + + // Set by primary key + $post->creator = $author2->id; + $this->assertEquals($author2->id, $post->author_id); + $this->assertEquals('Louie', $post->creator->name); + + // Nullify + $post->creator = null; + $this->assertNull($post->author_id); + $this->assertNull($post->creator); + } +} diff --git a/tests/Database/Relations/HasManyTest.php b/tests/Database/Relations/HasManyTest.php new file mode 100644 index 000000000..564684f2e --- /dev/null +++ b/tests/Database/Relations/HasManyTest.php @@ -0,0 +1,220 @@ + 'Stevie', 'email' => 'stevie@example.com']); + $post1 = Post::create(['title' => "First post", 'description' => "Yay!!"]); + $post2 = Post::create(['title' => "Second post", 'description' => "Woohoo!!"]); + $post3 = Post::create(['title' => "Third post", 'description' => "Yipiee!!"]); + $post4 = Post::make(['title' => "Fourth post", 'description' => "Hooray!!"]); + Model::reguard(); + + // Set by Model object + $author->posts = new Collection([$post1, $post2]); + $author->save(); + $this->assertEquals($author->id, $post1->author_id); + $this->assertEquals($author->id, $post2->author_id); + $this->assertEquals([ + 'First post', + 'Second post' + ], $author->posts->lists('title')); + + // Set by primary key + $postId = $post3->id; + $author->posts = $postId; + $author->save(); + $post3 = Post::find($postId); + $this->assertEquals($author->id, $post3->author_id); + $this->assertEquals([ + 'Third post' + ], $author->posts->lists('title')); + + // Nullify + $author->posts = null; + $author->save(); + $post3 = Post::find($postId); + $this->assertNull($post3->author_id); + $this->assertNull($post3->author); + + // Deferred in memory + $author->posts = $post4; + $this->assertEquals($author->id, $post4->author_id); + $this->assertEquals([ + 'Fourth post' + ], $author->posts->lists('title')); + } + + public function testSetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $post1 = Post::create(['title' => "First post", 'description' => "Yay!!"]); + $post2 = Post::create(['title' => "Second post", 'description' => "Woohoo!!"]); + $post3 = Post::create(['title' => "Third post", 'description' => "Yipiee!!"]); + $post4 = Post::make(['title' => "Fourth post", 'description' => "Hooray!!"]); + Model::reguard(); + + // Set by Model object + $author->messages = new Collection([$post1, $post2]); + $author->save(); + $this->assertEquals($author->id, $post1->author_id); + $this->assertEquals($author->id, $post2->author_id); + $this->assertEquals([ + 'First post', + 'Second post' + ], $author->messages->lists('title')); + + // Set by primary key + $postId = $post3->id; + $author->messages = $postId; + $author->save(); + $post3 = Post::find($postId); + $this->assertEquals($author->id, $post3->author_id); + $this->assertEquals([ + 'Third post' + ], $author->messages->lists('title')); + + // Nullify + $author->messages = null; + $author->save(); + $post3 = Post::find($postId); + $this->assertNull($post3->author_id); + $this->assertNull($post3->author); + + // Deferred in memory + $author->messages = $post4; + $this->assertEquals($author->id, $post4->author_id); + $this->assertEquals([ + 'Fourth post' + ], $author->messages->lists('title')); + } + + public function testGetRelationValue() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $post1 = Post::create(['title' => "First post", 'author_id' => $author->id]); + $post2 = Post::create(['title' => "Second post", 'author_id' => $author->id]); + Model::reguard(); + + $this->assertEquals([$post1->id, $post2->id], $author->getRelationValue('posts')); + } + + public function testGetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $post1 = Post::create(['title' => "First post", 'author_id' => $author->id]); + $post2 = Post::create(['title' => "Second post", 'author_id' => $author->id]); + Model::reguard(); + + $this->assertEquals([$post1->id, $post2->id], $author->getRelationValue('messages')); + } + + public function testDeferredBinding() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $post = Post::create(['title' => "First post", 'description' => "Yay!!"]); + Model::reguard(); + + $postId = $post->id; + + // Deferred add + $author->posts()->add($post, $sessionKey); + $this->assertNull($post->author_id); + $this->assertEmpty($author->posts); + + $this->assertEquals(0, $author->posts()->count()); + $this->assertEquals(1, $author->posts()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $post = Post::find($postId); + $this->assertEquals(1, $author->posts()->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals([ + 'First post' + ], $author->posts->lists('title')); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->posts()->remove($post, $sessionKey); + $this->assertEquals(1, $author->posts()->count()); + $this->assertEquals(0, $author->posts()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals([ + 'First post' + ], $author->posts->lists('title')); + + // Commit deferred + $author->save(null, $sessionKey); + $post = Post::find($postId); + $this->assertEquals(0, $author->posts()->count()); + $this->assertNull($post->author_id); + $this->assertEmpty($author->posts); + } + + public function testDeferredBindingLaravelRelation() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $post = Post::create(['title' => "First post", 'description' => "Yay!!"]); + Model::reguard(); + + $postId = $post->id; + + // Deferred add + $author->messages()->add($post, $sessionKey); + $this->assertNull($post->author_id); + $this->assertEmpty($author->messages); + + $this->assertEquals(0, $author->messages()->count()); + $this->assertEquals(1, $author->messages()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $post = Post::find($postId); + $this->assertEquals(1, $author->messages()->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals([ + 'First post' + ], $author->messages->lists('title')); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->messages()->remove($post, $sessionKey); + $this->assertEquals(1, $author->messages()->count()); + $this->assertEquals(0, $author->messages()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals([ + 'First post' + ], $author->messages->lists('title')); + + // Commit deferred + $author->save(null, $sessionKey); + $post = Post::find($postId); + $this->assertEquals(0, $author->messages()->count()); + $this->assertNull($post->author_id); + $this->assertEmpty($author->messages); + } +} diff --git a/tests/Database/Relations/HasManyThroughTest.php b/tests/Database/Relations/HasManyThroughTest.php new file mode 100644 index 000000000..ffaa5bf9a --- /dev/null +++ b/tests/Database/Relations/HasManyThroughTest.php @@ -0,0 +1,139 @@ + 'Australia']); + $author1 = Author::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + $author2 = Author::create(['name' => 'Louie', 'email' => 'louie@email.tld']); + $post1 = Post::create(['title' => "First post", 'description' => "Yay!!"]); + $post2 = Post::create(['title' => "Second post", 'description' => "Woohoo!!"]); + $post3 = Post::create(['title' => "Third post", 'description' => "Yipiee!!"]); + $post4 = Post::make(['title' => "Fourth post", 'description' => "Hooray!!"]); + Model::reguard(); + + // Set data + $author1->country = $country; + $author2->country = $country; + + $author1->posts = new Collection([$post1, $post2]); + $author2->posts = new Collection([$post3, $post4]); + + $author1->save(); + $author2->save(); + + $country = Country::with([ + 'posts' + ])->find($country->id); + + $this->assertEquals([ + $post1->id, + $post2->id, + $post3->id, + $post4->id + ], $country->posts->pluck('id')->toArray()); + } + + public function testGetLaravelRelation() + { + Model::unguard(); + $country = Country::create(['name' => 'Australia']); + $author1 = Author::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + $author2 = Author::create(['name' => 'Louie', 'email' => 'louie@email.tld']); + $post1 = Post::create(['title' => "First post", 'description' => "Yay!!"]); + $post2 = Post::create(['title' => "Second post", 'description' => "Woohoo!!"]); + $post3 = Post::create(['title' => "Third post", 'description' => "Yipiee!!"]); + $post4 = Post::make(['title' => "Fourth post", 'description' => "Hooray!!"]); + Model::reguard(); + + // Set data + $author1->country = $country; + $author2->country = $country; + + $author1->messages = new Collection([$post1, $post2]); + $author2->messages = new Collection([$post3, $post4]); + + $author1->save(); + $author2->save(); + + $country = Country::with([ + 'messages' + ])->find($country->id); + + $this->assertEquals([ + $post1->id, + $post2->id, + $post3->id, + $post4->id + ], $country->messages->pluck('id')->toArray()); + } + + public function testCount() + { + Model::unguard(); + $country1 = Country::create(['name' => 'Australia']); + $country2 = Country::create(['name' => 'Canada']); + $author1 = Author::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + $author2 = Author::create(['name' => 'Louie', 'email' => 'louie@email.tld']); + $post1 = Post::create(['title' => "First post", 'description' => "Yay!!"]); + $post2 = Post::create(['title' => "Second post", 'description' => "Woohoo!!"]); + $post3 = Post::create(['title' => "Third post", 'description' => "Yipiee!!"]); + Model::reguard(); + + // Set data + $author1->country = $country1; + $author2->country = $country2; + + $author1->posts = new Collection([$post1, $post2]); + $author2->posts = new Collection([$post3]); + + $author1->save(); + $author2->save(); + + $country1 = Country::find($country1->id); + $country2 = Country::find($country2->id); + + $this->assertEquals(2, $country1->posts_count->first()->count); + $this->assertEquals(1, $country2->posts_count()->first()->count); + } + + public function testCountLaravelRelation() + { + Model::unguard(); + $country1 = Country::create(['name' => 'Australia']); + $country2 = Country::create(['name' => 'Canada']); + $author1 = Author::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + $author2 = Author::create(['name' => 'Louie', 'email' => 'louie@email.tld']); + $post1 = Post::create(['title' => "First post", 'description' => "Yay!!"]); + $post2 = Post::create(['title' => "Second post", 'description' => "Woohoo!!"]); + $post3 = Post::create(['title' => "Third post", 'description' => "Yipiee!!"]); + Model::reguard(); + + // Set data + $author1->country = $country1; + $author2->country = $country2; + + $author1->messages = new Collection([$post1, $post2]); + $author2->messages = new Collection([$post3]); + + $author1->save(); + $author2->save(); + + $country1 = Country::find($country1->id); + $country2 = Country::find($country2->id); + + $this->assertEquals(2, $country1->messagesCount->first()->count); + $this->assertEquals(1, $country2->messagesCount()->first()->count); + } +} diff --git a/tests/Database/Relations/HasOneTest.php b/tests/Database/Relations/HasOneTest.php new file mode 100644 index 000000000..fe76b67a4 --- /dev/null +++ b/tests/Database/Relations/HasOneTest.php @@ -0,0 +1,245 @@ + 'Stevie', 'email' => 'stevie@example.com']); + $phone1 = Phone::create(['number' => '0404040404']); + $phone2 = Phone::create(['number' => '0505050505']); + $phone3 = Phone::make(['number' => '0606060606']); + Model::reguard(); + + // Set by Model object + $author->phone = $phone1; + $author->save(); + $this->assertEquals($author->id, $phone1->author_id); + $this->assertEquals('0404040404', $author->phone->number); + + // Double check + $phone1 = Phone::find($phone1->id); + $this->assertEquals($author->id, $phone1->author_id); + + // Set by primary key + $phoneId = $phone2->id; + $author->phone = $phoneId; + $author->save(); + $phone2 = Phone::find($phoneId); + $this->assertEquals($author->id, $phone2->author_id); + $this->assertEquals('0505050505', $author->phone->number); + + // Ensure relationship is "stolen" from first model + $phone1 = Phone::find($phone1->id); + $this->assertNotEquals($author->id, $phone1->author_id); + + // Nullify + $author->phone = null; + $author->save(); + $phone2 = Phone::find($phoneId); + $this->assertNull($phone2->author_id); + $this->assertNull($phone2->author); + + // Deferred in memory + $author->phone = $phone3; + $this->assertEquals('0606060606', $author->phone->number); + $this->assertEquals($author->id, $phone3->author_id); + } + + public function testSetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $phone1 = Phone::create(['number' => '0404040404']); + $phone2 = Phone::create(['number' => '0505050505']); + $phone3 = Phone::make(['number' => '0606060606']); + Model::reguard(); + + // Set by Model object + $author->contactNumber = $phone1; + $author->save(); + $this->assertEquals($author->id, $phone1->author_id); + $this->assertEquals('0404040404', $author->contactNumber->number); + + // Double check + $phone1 = Phone::find($phone1->id); + $this->assertEquals($author->id, $phone1->author_id); + + // Set by primary key + $phoneId = $phone2->id; + $author->contactNumber = $phoneId; + $author->save(); + $phone2 = Phone::find($phoneId); + $this->assertEquals($author->id, $phone2->author_id); + $this->assertEquals('0505050505', $author->contactNumber->number); + + // Ensure relationship is "stolen" from first model + $phone1 = Phone::find($phone1->id); + $this->assertNotEquals($author->id, $phone1->author_id); + + // Nullify + $author->contactNumber = null; + $author->save(); + $phone2 = Phone::find($phoneId); + $this->assertNull($phone2->author_id); + $this->assertNull($phone2->author); + + // Deferred in memory + $author->contactNumber = $phone3; + $this->assertEquals('0606060606', $author->contactNumber->number); + $this->assertEquals($author->id, $phone3->author_id); + } + + public function testSetRelationValueTwice() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $phone = Phone::create(['number' => '0505050505']); + Model::reguard(); + + $phoneId = $phone->id; + $author->phone = $phoneId; + $author->save(); + + $author->phone = $phoneId; + $author->save(); + + $phone = Phone::find($phoneId); + $this->assertEquals($author->id, $phone->author_id); + $this->assertEquals('0505050505', $author->phone->number); + } + + public function testSetRelationValueTwiceLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $phone = Phone::create(['number' => '0505050505']); + Model::reguard(); + + $phoneId = $phone->id; + $author->contactNumber = $phoneId; + $author->save(); + + $author->contactNumber = $phoneId; + $author->save(); + + $phone = Phone::find($phoneId); + $this->assertEquals($author->id, $phone->author_id); + $this->assertEquals('0505050505', $author->contactNumber->number); + } + + public function testGetRelationValue() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $phone = Phone::create(['number' => '0404040404', 'author_id' => $author->id]); + Model::reguard(); + + $this->assertEquals($phone->id, $author->getRelationValue('phone')); + } + + public function testGetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $phone = Phone::create(['number' => '0404040404', 'author_id' => $author->id]); + Model::reguard(); + + $this->assertEquals($phone->id, $author->getRelationValue('contactNumber')); + } + + public function testDeferredBinding() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $phone = Phone::create(['number' => '0404040404']); + Model::reguard(); + + $phoneId = $phone->id; + + // Deferred add + $author->phone()->add($phone, $sessionKey); + $this->assertNull($phone->author_id); + $this->assertNull($author->phone); + + $this->assertEquals(0, $author->phone()->count()); + $this->assertEquals(1, $author->phone()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $phone = Phone::find($phoneId); + $this->assertEquals(1, $author->phone()->count()); + $this->assertEquals($author->id, $phone->author_id); + $this->assertEquals('0404040404', $author->phone->number); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->phone()->remove($phone, $sessionKey); + $this->assertEquals(1, $author->phone()->count()); + $this->assertEquals(0, $author->phone()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $phone->author_id); + $this->assertEquals('0404040404', $author->phone->number); + + // Commit deferred + $author->save(null, $sessionKey); + $phone = Phone::find($phoneId); + $this->assertEquals(0, $author->phone()->count()); + $this->assertNull($phone->author_id); + $this->assertNull($author->phone); + } + + public function testDeferredBindingLaravelRelation() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $phone = Phone::create(['number' => '0404040404']); + Model::reguard(); + + $phoneId = $phone->id; + + // Deferred add + $author->contactNumber()->add($phone, $sessionKey); + $this->assertNull($phone->author_id); + $this->assertNull($author->contactNumber); + + $this->assertEquals(0, $author->contactNumber()->count()); + $this->assertEquals(1, $author->contactNumber()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $phone = Phone::find($phoneId); + $this->assertEquals(1, $author->contactNumber()->count()); + $this->assertEquals($author->id, $phone->author_id); + $this->assertEquals('0404040404', $author->contactNumber->number); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->contactNumber()->remove($phone, $sessionKey); + $this->assertEquals(1, $author->contactNumber()->count()); + $this->assertEquals(0, $author->contactNumber()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $phone->author_id); + $this->assertEquals('0404040404', $author->contactNumber->number); + + // Commit deferred + $author->save(null, $sessionKey); + $phone = Phone::find($phoneId); + $this->assertEquals(0, $author->contactNumber()->count()); + $this->assertNull($phone->author_id); + $this->assertNull($author->contactNumber); + } +} diff --git a/tests/Database/Relations/HasOneThroughTest.php b/tests/Database/Relations/HasOneThroughTest.php new file mode 100644 index 000000000..d27c680fa --- /dev/null +++ b/tests/Database/Relations/HasOneThroughTest.php @@ -0,0 +1,52 @@ + '08 1234 5678']); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + Model::reguard(); + + // Set data + $author->phone = $phone; + $author->user = $user; + $author->save(); + + $user = User::with([ + 'phone' + ])->find($user->id); + + $this->assertEquals($phone->id, $user->phone->id); + } + + public function testGetLaravelRelation() + { + Model::unguard(); + $phone = Phone::create(['number' => '08 1234 5678']); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + Model::reguard(); + + // Set data + $author->phone = $phone; + $author->user = $user; + $author->save(); + + $user = User::with([ + 'contactNumber' + ])->find($user->id); + + $this->assertEquals($phone->id, $user->contactNumber->id); + } +} diff --git a/tests/Database/Relations/MorphManyTest.php b/tests/Database/Relations/MorphManyTest.php new file mode 100644 index 000000000..9b3dbc2fc --- /dev/null +++ b/tests/Database/Relations/MorphManyTest.php @@ -0,0 +1,310 @@ + 'Stevie', 'email' => 'stevie@example.com']); + $event1 = EventLog::create(['action' => "user-created"]); + $event2 = EventLog::create(['action' => "user-updated"]); + $event3 = EventLog::create(['action' => "user-deleted"]); + $event4 = EventLog::make(['action' => "user-restored"]); + Model::reguard(); + + // Set by Model object + $author->event_log = new Collection([$event1, $event2]); + $author->save(); + $this->assertEquals($author->id, $event1->related_id); + $this->assertEquals($author->id, $event2->related_id); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event1->related_type); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event2->related_type); + $this->assertEquals([ + 'user-created', + 'user-updated' + ], $author->event_log->lists('action')); + + // Set by primary key + $eventId = $event3->id; + $author->event_log = $eventId; + $author->save(); + $event3 = EventLog::find($eventId); + $this->assertEquals($author->id, $event3->related_id); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event3->related_type); + $this->assertEquals([ + 'user-deleted' + ], $author->event_log->lists('action')); + + // Nullify + $author->event_log = null; + $author->save(); + $event3 = EventLog::find($eventId); + $this->assertNull($event3->related_type); + $this->assertNull($event3->related_id); + $this->assertNull($event3->related); + + // Deferred in memory + $author->event_log = $event4; + $this->assertEquals($author->id, $event4->related_id); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event4->related_type); + $this->assertEquals([ + 'user-restored' + ], $author->event_log->lists('action')); + } + + public function testSetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $event1 = EventLog::create(['action' => "user-created"]); + $event2 = EventLog::create(['action' => "user-updated"]); + $event3 = EventLog::create(['action' => "user-deleted"]); + $event4 = EventLog::make(['action' => "user-restored"]); + Model::reguard(); + + // Set by Model object + $author->auditLogs = new Collection([$event1, $event2]); + $author->save(); + $this->assertEquals($author->id, $event1->related_id); + $this->assertEquals($author->id, $event2->related_id); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event1->related_type); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event2->related_type); + $this->assertEquals([ + 'user-created', + 'user-updated' + ], $author->auditLogs->lists('action')); + + // Set by primary key + $eventId = $event3->id; + $author->auditLogs = $eventId; + $author->save(); + $event3 = EventLog::find($eventId); + $this->assertEquals($author->id, $event3->related_id); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event3->related_type); + $this->assertEquals([ + 'user-deleted' + ], $author->auditLogs->lists('action')); + + // Nullify + $author->auditLogs = null; + $author->save(); + $event3 = EventLog::find($eventId); + $this->assertNull($event3->related_type); + $this->assertNull($event3->related_id); + $this->assertNull($event3->related); + + // Deferred in memory + $author->auditLogs = $event4; + $this->assertEquals($author->id, $event4->related_id); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event4->related_type); + $this->assertEquals([ + 'user-restored' + ], $author->auditLogs->lists('action')); + } + + public function testGetRelationValue() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $event1 = EventLog::create(['action' => "user-created", 'related_id' => $author->id, 'related_type' => 'Winter\Storm\Tests\Database\Fixtures\Author']); + $event2 = EventLog::create(['action' => "user-updated", 'related_id' => $author->id, 'related_type' => 'Winter\Storm\Tests\Database\Fixtures\Author']); + Model::reguard(); + + $this->assertEquals([$event1->id, $event2->id], $author->getRelationValue('event_log')); + } + + public function testGetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $event1 = EventLog::create(['action' => "user-created", 'related_id' => $author->id, 'related_type' => 'Winter\Storm\Tests\Database\Fixtures\Author']); + $event2 = EventLog::create(['action' => "user-updated", 'related_id' => $author->id, 'related_type' => 'Winter\Storm\Tests\Database\Fixtures\Author']); + Model::reguard(); + + $this->assertEquals([$event1->id, $event2->id], $author->getRelationValue('auditLogs')); + } + + public function testDeferredBinding() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $event = EventLog::create(['action' => "user-created"]); + $post = Post::create(['title' => 'Hello world!']); + $tagForAuthor = Tag::create(['name' => 'ForAuthor']); + $tagForPost = Tag::create(['name' => 'ForPost']); + Model::reguard(); + + $eventId = $event->id; + + // Deferred add + $author->event_log()->add($event, $sessionKey); + $this->assertNull($event->related_id); + $this->assertEmpty($author->event_log); + + $this->assertEquals(0, $author->event_log()->count()); + $this->assertEquals(1, $author->event_log()->withDeferred($sessionKey)->count()); + + $author->tags()->add($tagForAuthor, $sessionKey, ['added_by' => 99]); + $this->assertEmpty($author->tags); + + $this->assertEquals(0, $author->tags()->count()); + $this->assertEquals(1, $author->tags()->withDeferred($sessionKey)->count()); + + $tagForPost->posts()->add($post, $sessionKey, ['added_by' => 88]); + $this->assertEmpty($tagForPost->posts); + + $this->assertEquals(0, $tagForPost->posts()->count()); + $this->assertEquals(1, $tagForPost->posts()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $event = EventLog::find($eventId); + $this->assertEquals(1, $author->event_log()->count()); + $this->assertEquals($author->id, $event->related_id); + $this->assertEquals([ + 'user-created' + ], $author->event_log->lists('action')); + + $this->assertEquals(1, $author->tags()->count()); + $this->assertEquals([$tagForAuthor->id], $author->tags->lists('id')); + $this->assertEquals(99, $author->tags->first()->pivot->added_by); + + $tagForPost->save(null, $sessionKey); + $this->assertEquals(1, $tagForPost->posts()->count()); + $this->assertEquals([$post->id], $tagForPost->posts->lists('id')); + $this->assertEquals(88, $tagForPost->posts->first()->pivot->added_by); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->event_log()->remove($event, $sessionKey); + $this->assertEquals(1, $author->event_log()->count()); + $this->assertEquals(0, $author->event_log()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $event->related_id); + $this->assertEquals([ + 'user-created' + ], $author->event_log->lists('action')); + + $author->tags()->remove($tagForAuthor, $sessionKey); + $this->assertEquals(1, $author->tags()->count()); + $this->assertEquals(0, $author->tags()->withDeferred($sessionKey)->count()); + $this->assertEquals([$tagForAuthor->id], $author->tags->lists('id')); + $this->assertEquals(99, $author->tags->first()->pivot->added_by); + + $tagForPost->posts()->remove($post, $sessionKey); + $this->assertEquals(1, $tagForPost->posts()->count()); + $this->assertEquals(0, $tagForPost->posts()->withDeferred($sessionKey)->count()); + $this->assertEquals([$post->id], $tagForPost->posts->lists('id')); + $this->assertEquals(88, $tagForPost->posts->first()->pivot->added_by); + + // Commit deferred (model is deleted as per definition) + $author->save(null, $sessionKey); + $event = EventLog::find($eventId); + $this->assertEquals(0, $author->event_log()->count()); + $this->assertNull($event); + $this->assertEmpty($author->event_log); + + $this->assertEmpty(0, $author->tags); + $this->assertEmpty(0, $tagForPost->posts); + } + + public function testDeferredBindingLaravelRelation() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $event = EventLog::create(['action' => "user-created"]); + $post = Post::create(['title' => 'Hello world!']); + $tagForAuthor = Tag::create(['name' => 'ForAuthor']); + $tagForPost = Tag::create(['name' => 'ForPost']); + Model::reguard(); + + $eventId = $event->id; + + // Deferred add + $author->auditLogs()->add($event, $sessionKey); + $this->assertNull($event->related_id); + $this->assertEmpty($author->auditLogs); + + $this->assertEquals(0, $author->auditLogs()->count()); + $this->assertEquals(1, $author->auditLogs()->withDeferred($sessionKey)->count()); + + $author->tags()->add($tagForAuthor, $sessionKey, ['added_by' => 99]); + $this->assertEmpty($author->tags); + + $this->assertEquals(0, $author->tags()->count()); + $this->assertEquals(1, $author->tags()->withDeferred($sessionKey)->count()); + + $tagForPost->posts()->add($post, $sessionKey, ['added_by' => 88]); + $this->assertEmpty($tagForPost->posts); + + $this->assertEquals(0, $tagForPost->posts()->count()); + $this->assertEquals(1, $tagForPost->posts()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $event = EventLog::find($eventId); + $this->assertEquals(1, $author->auditLogs()->count()); + $this->assertEquals($author->id, $event->related_id); + $this->assertEquals([ + 'user-created' + ], $author->auditLogs->lists('action')); + + $this->assertEquals(1, $author->tags()->count()); + $this->assertEquals([$tagForAuthor->id], $author->tags->lists('id')); + $this->assertEquals(99, $author->tags->first()->pivot->added_by); + + $tagForPost->save(null, $sessionKey); + $this->assertEquals(1, $tagForPost->posts()->count()); + $this->assertEquals([$post->id], $tagForPost->posts->lists('id')); + $this->assertEquals(88, $tagForPost->posts->first()->pivot->added_by); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->auditLogs()->remove($event, $sessionKey); + $this->assertEquals(1, $author->auditLogs()->count()); + $this->assertEquals(0, $author->auditLogs()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $event->related_id); + $this->assertEquals([ + 'user-created' + ], $author->auditLogs->lists('action')); + + $author->tags()->remove($tagForAuthor, $sessionKey); + $this->assertEquals(1, $author->tags()->count()); + $this->assertEquals(0, $author->tags()->withDeferred($sessionKey)->count()); + $this->assertEquals([$tagForAuthor->id], $author->tags->lists('id')); + $this->assertEquals(99, $author->tags->first()->pivot->added_by); + + $tagForPost->posts()->remove($post, $sessionKey); + $this->assertEquals(1, $tagForPost->posts()->count()); + $this->assertEquals(0, $tagForPost->posts()->withDeferred($sessionKey)->count()); + $this->assertEquals([$post->id], $tagForPost->posts->lists('id')); + $this->assertEquals(88, $tagForPost->posts->first()->pivot->added_by); + + // Commit deferred (model is deleted as per definition) + $author->save(null, $sessionKey); + $event = EventLog::find($eventId); + $this->assertEquals(0, $author->auditLogs()->count()); + $this->assertNull($event); + $this->assertEmpty($author->auditLogs); + + $this->assertEmpty(0, $author->tags); + $this->assertEmpty(0, $tagForPost->posts); + } +} diff --git a/tests/Database/Relations/MorphOneTest.php b/tests/Database/Relations/MorphOneTest.php new file mode 100644 index 000000000..005e334ff --- /dev/null +++ b/tests/Database/Relations/MorphOneTest.php @@ -0,0 +1,348 @@ + "First post", 'description' => "Yay!!"]); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $meta1 = Meta::create([ + 'meta_title' => 'Question', + 'meta_description' => 'Industry', + 'meta_keywords' => 'major', + 'canonical_url' => 'http://google.com/search/jobs', + 'redirect_url' => 'http://google.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + $meta2 = Meta::create([ + 'meta_title' => 'Comment', + 'meta_description' => 'Social', + 'meta_keywords' => 'startup', + 'canonical_url' => 'http://facebook.com/search/users', + 'redirect_url' => 'http://facebook.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + $meta3 = Meta::make([ + 'meta_title' => 'Answer', + 'meta_description' => 'Employment', + 'meta_keywords' => 'minor', + 'canonical_url' => 'http://yahoo.com/search/stats', + 'redirect_url' => 'http://yahoo.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + // Set by Model object + $post->meta = $meta1; + $post->save(); + $this->assertEquals($post->id, $meta1->taggable_id); + $this->assertEquals(get_class($post), $meta1->taggable_type); + $this->assertEquals('Question', $post->meta->meta_title); + + // Double check + $meta1 = Meta::find($meta1->id); + $this->assertEquals($post->id, $meta1->taggable_id); + $this->assertEquals(get_class($post), $meta1->taggable_type); + + // Set by primary key + $metaId = $meta2->id; + $author->meta = $metaId; + $author->save(); + $meta2 = Meta::find($metaId); + $this->assertEquals($author->id, $meta2->taggable_id); + $this->assertEquals(get_class($author), $meta2->taggable_type); + $this->assertEquals('Comment', $author->meta->meta_title); + + // Nullify + $author->meta = null; + $author->save(); + $meta = Meta::find($metaId); + $this->assertNull($meta->taggable_type); + $this->assertNull($meta->taggable_id); + $this->assertNull($meta->taggable); + + // Deferred in memory + $author->meta = $meta3; + $this->assertEquals('Answer', $author->meta->meta_title); + $this->assertEquals($author->id, $meta3->taggable_id); + } + + public function testSetRelationValueLaravelRelation() + { + Model::unguard(); + $post = Post::create(['title' => "First post", 'description' => "Yay!!"]); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $meta1 = Meta::create([ + 'meta_title' => 'Question', + 'meta_description' => 'Industry', + 'meta_keywords' => 'major', + 'canonical_url' => 'http://google.com/search/jobs', + 'redirect_url' => 'http://google.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + $meta2 = Meta::create([ + 'meta_title' => 'Comment', + 'meta_description' => 'Social', + 'meta_keywords' => 'startup', + 'canonical_url' => 'http://facebook.com/search/users', + 'redirect_url' => 'http://facebook.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + $meta3 = Meta::make([ + 'meta_title' => 'Answer', + 'meta_description' => 'Employment', + 'meta_keywords' => 'minor', + 'canonical_url' => 'http://yahoo.com/search/stats', + 'redirect_url' => 'http://yahoo.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + // Set by Model object + $post->info = $meta1; + $post->save(); + $this->assertEquals($post->id, $meta1->taggable_id); + $this->assertEquals(get_class($post), $meta1->taggable_type); + $this->assertEquals('Question', $post->info->meta_title); + + // Double check + $meta1 = Meta::find($meta1->id); + $this->assertEquals($post->id, $meta1->taggable_id); + $this->assertEquals(get_class($post), $meta1->taggable_type); + + // Set by primary key + $metaId = $meta2->id; + $author->info = $metaId; + $author->save(); + $meta2 = Meta::find($metaId); + $this->assertEquals($author->id, $meta2->taggable_id); + $this->assertEquals(get_class($author), $meta2->taggable_type); + $this->assertEquals('Comment', $author->info->meta_title); + + // Nullify + $author->info = null; + $author->save(); + $meta = Meta::find($metaId); + $this->assertNull($meta->taggable_type); + $this->assertNull($meta->taggable_id); + $this->assertNull($meta->taggable); + + // Deferred in memory + $author->info = $meta3; + $this->assertEquals('Answer', $author->info->meta_title); + $this->assertEquals($author->id, $meta3->taggable_id); + } + + public function testSetRelationValueTwice() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $meta = Meta::create([ + 'meta_title' => 'Question', + 'meta_description' => 'Industry', + 'meta_keywords' => 'major', + 'canonical_url' => 'http://google.com/search/jobs', + 'redirect_url' => 'http://google.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + $metaId = $meta->id; + $author->meta = $metaId; + $author->save(); + + $author->meta = $metaId; + $author->save(); + + $meta = Meta::find($metaId); + $this->assertEquals($author->id, $meta->taggable_id); + $this->assertEquals(get_class($author), $meta->taggable_type); + } + + public function testSetRelationValueTwiceLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $meta = Meta::create([ + 'meta_title' => 'Question', + 'meta_description' => 'Industry', + 'meta_keywords' => 'major', + 'canonical_url' => 'http://google.com/search/jobs', + 'redirect_url' => 'http://google.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + $metaId = $meta->id; + $author->info = $metaId; + $author->save(); + + $author->info = $metaId; + $author->save(); + + $meta = Meta::find($metaId); + $this->assertEquals($author->id, $meta->taggable_id); + $this->assertEquals(get_class($author), $meta->taggable_type); + } + + public function testGetRelationValue() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $meta = Meta::create([ + 'taggable_id' => $author->id, + 'taggable_type' => get_class($author), + 'meta_title' => 'Question', + 'meta_description' => 'Industry', + 'meta_keywords' => 'major', + 'canonical_url' => 'http://google.com/search/jobs', + 'redirect_url' => 'http://google.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + $this->assertEquals($meta->id, $author->getRelationValue('meta')); + } + + public function testGetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $meta = Meta::create([ + 'taggable_id' => $author->id, + 'taggable_type' => get_class($author), + 'meta_title' => 'Question', + 'meta_description' => 'Industry', + 'meta_keywords' => 'major', + 'canonical_url' => 'http://google.com/search/jobs', + 'redirect_url' => 'http://google.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + $this->assertEquals($meta->id, $author->getRelationValue('info')); + } + + public function testDeferredBinding() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $meta = Meta::create([ + 'meta_title' => 'Comment', + 'meta_description' => 'Social', + 'meta_keywords' => 'startup', + 'canonical_url' => 'http://facebook.com/search/users', + 'redirect_url' => 'http://facebook.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + $metaId = $meta->id; + + // Deferred add + $author->meta()->add($meta, $sessionKey); + $this->assertNull($meta->taggable_id); + $this->assertNull($author->meta); + + $this->assertEquals(0, $author->meta()->count()); + $this->assertEquals(1, $author->meta()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $meta = Meta::find($metaId); + $this->assertEquals(1, $author->meta()->count()); + $this->assertEquals($author->id, $meta->taggable_id); + $this->assertEquals('Comment', $author->meta->meta_title); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->meta()->remove($meta, $sessionKey); + $this->assertEquals(1, $author->meta()->count()); + $this->assertEquals(0, $author->meta()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $meta->taggable_id); + $this->assertEquals('Comment', $author->meta->meta_title); + + // Commit deferred + $author->save(null, $sessionKey); + $meta = Meta::find($metaId); + $this->assertEquals(0, $author->meta()->count()); + $this->assertNull($meta->taggable_id); + $this->assertNull($author->meta); + } + + public function testDeferredBindingLaravelRelation() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $meta = Meta::create([ + 'meta_title' => 'Comment', + 'meta_description' => 'Social', + 'meta_keywords' => 'startup', + 'canonical_url' => 'http://facebook.com/search/users', + 'redirect_url' => 'http://facebook.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + $metaId = $meta->id; + + // Deferred add + $author->info()->add($meta, $sessionKey); + $this->assertNull($meta->taggable_id); + $this->assertNull($author->info); + + $this->assertEquals(0, $author->info()->count()); + $this->assertEquals(1, $author->info()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $meta = Meta::find($metaId); + $this->assertEquals(1, $author->info()->count()); + $this->assertEquals($author->id, $meta->taggable_id); + $this->assertEquals('Comment', $author->info->meta_title); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->info()->remove($meta, $sessionKey); + $this->assertEquals(1, $author->info()->count()); + $this->assertEquals(0, $author->info()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $meta->taggable_id); + $this->assertEquals('Comment', $author->info->meta_title); + + // Commit deferred + $author->save(null, $sessionKey); + $meta = Meta::find($metaId); + $this->assertEquals(0, $author->info()->count()); + $this->assertNull($meta->taggable_id); + $this->assertNull($author->info); + } +} diff --git a/tests/Database/RelationsTest.php b/tests/Database/RelationsTest.php deleted file mode 100644 index ec11308d2..000000000 --- a/tests/Database/RelationsTest.php +++ /dev/null @@ -1,386 +0,0 @@ -seeded = [ - 'posts' => [], - 'categories' => [], - 'labels' => [], - 'tags' => [] - ]; - - $this->createTables(); - } - - public function testBelongsToManyCount() - { - $post = $this->seeded['posts'][0]; - $this->assertEquals(1, $post->categories()->count()); - $this->assertEquals(2, $post->tags()->count()); - $this->assertEquals(1, $post->labels()->count()); - $this->assertEquals(3, $post->terms()->count()); - - $post = $this->seeded['posts'][1]; - $this->assertEquals(1, $post->categories()->count()); - $this->assertEquals(0, $post->tags()->count()); - $this->assertEquals(1, $post->labels()->count()); - $this->assertEquals(1, $post->terms()->count()); - } - - public function testBelongsToManySyncAll() - { - $post = $this->seeded['posts'][0]; - - $this->assertEquals(1, $post->categories()->count()); - $this->assertEquals(1, $post->labels()->count()); - - $post->categories()->sync([ - $this->seeded['categories'][0]->id, - $this->seeded['categories'][1]->id, - ]); - $post->labels()->sync([ - $this->seeded['labels'][0]->id, - $this->seeded['labels'][1]->id, - ]); - - $this->assertEquals(2, $post->categories()->count()); - $this->assertEquals(2, $post->labels()->count()); - } - - public function testBelongsToManySyncTags() - { - $post = $this->seeded['posts'][0]; - - $this->assertEquals(1, $post->labels()->count()); - $this->assertEquals(2, $post->tags()->count()); - - $post->labels()->detach(); - $post->tags()->sync([ - $this->seeded['tags'][0]->id, - ]); - - $this->assertEquals(0, $post->labels()->count()); - $this->assertEquals(1, $post->tags()->count()); - $this->assertEquals($this->seeded['tags'][0]->id, $post->tags()->first()->id); - - $this->assertEquals(1, $post->terms()->count()); - } - - public function testBelongsToManyDetach() - { - $post = $this->seeded['posts'][0]; - - $post->labels()->detach(); - $post->tags()->detach(); - - $this->assertEquals(0, $post->labels()->count()); - $this->assertEquals(0, $post->tags()->count()); - $this->assertEquals(0, $post->terms()->count()); - } - - public function testBelongsToManyDetachOneTag() - { - $post = $this->seeded['posts'][0]; - - $id = $post->tags()->get()->last()->id; - $post->tags()->detach([$id]); - - $this->assertEquals(1, $post->labels()->count()); - $this->assertEquals(1, $post->tags()->count()); - $this->assertEquals(2, $post->terms()->count()); - } - - public function testBelongsToManyDetachAllWithScope() - { - $category = $this->seeded['categories'][0]; - $post = $this->seeded['posts'][0]; - - $category->posts()->detach(); - $post->reloadRelations(); - - $this->assertEquals(0, $category->posts()->count()); - $this->assertEquals(0, $post->categories()->count()); - } - - public function testPivotData() - { - $data = 'My Pivot Data'; - $post = Post::first(); - - $id = $post->categories()->get()->last()->id; - $updated = $post->categories()->updateExistingPivot($id, ['data' => $data]); - $this->assertTrue($updated === 1); - - $category = $post->categories()->find($id); - $this->assertEquals($data, $category->pivot->data); - } - - public function testAddWithPivotData() - { - $post = Post::first(); - $this->assertEquals(1, count($post->categories)); - - $post->categories()->add($this->seeded['categories'][1], null, ['data'=>'Hello World!']); - - $this->assertEquals(2, count($post->categories)); - $this->assertEquals('Hello World!', $post->categories()->get()->last()->pivot->data); - } - - public function testTerms() - { - $post = Post::create([ - 'title' => 'B Post', - ]); - - $term1 = Term::create(['name' => 'term #1', 'type' => 'any']); - $term2 = Term::create(['name' => 'term #2', 'type' => 'any']); - $term3 = Term::create(['name' => 'term #3', 'type' => 'any']); - - // Add/remove to collection - $this->assertFalse($post->terms->contains($term1->id)); - $post->terms()->add($term1); - $post->terms()->add($term2); - $this->assertTrue($post->terms->contains($term1->id)); - $this->assertTrue($post->terms->contains($term2->id)); - - // Set by Model object - $post->terms = $term1; - $this->assertEquals(1, $post->terms->count()); - $this->assertEquals('term #1', $post->terms->first()->name); - - $post->terms = [$term1, $term2, $term3]; - $this->assertEquals(3, $post->terms->count()); - - // Set by primary key - $post->terms = $term2->id; - $this->assertEquals(1, $post->terms->count()); - $this->assertEquals('term #2', $post->terms->first()->name); - - $post->terms = [$term2->id, $term3->id]; - $this->assertEquals(2, $post->terms->count()); - - // Nullify - $post->terms = null; - $this->assertEquals(0, $post->terms->count()); - - // Extra nullify checks (still exists in DB until saved) - $post->reloadRelations('terms'); - $this->assertEquals(2, $post->terms->count()); - $post->save(); - $post->reloadRelations('terms'); - $this->assertEquals(0, $post->terms->count()); - - // Deferred in memory - $post->terms = [$term2->id, $term3->id]; - $this->assertEquals(2, $post->terms->count()); - $this->assertEquals('term #2', $post->terms->first()->name); - } - - public function testUndefinedMorphsRelation() - { - $this->expectException(BadMethodCallException::class); - - $morphs = new Morphs; - $morphs->unknownRelation(); - } - - public function testDefinedMorphsRelation() - { - $morphs = new Morphs; - $this->assertNotEmpty($morphs->related()); - } - - protected function createTables() - { - $this->getBuilder()->create('posts', function ($table) { - $table->increments('id'); - $table->string('title')->default(''); - $table->boolean('published')->nullable(); - $table->dateTime('published_at')->nullable(); - $table->timestamps(); - }); - - $this->getBuilder()->create('terms', function ($table) { - $table->increments('id'); - $table->string('type')->index(); - $table->string('name'); - $table->timestamps(); - }); - - $this->getBuilder()->create('posts_terms', function ($table) { - $table->primary(['post_id', 'term_id']); - $table->unsignedInteger('post_id'); - $table->unsignedInteger('term_id'); - $table->string('data')->nullable(); - $table->timestamps(); - }); - - $this->getBuilder()->create('categories', function ($table) { - $table->increments('id'); - $table->string('name'); - $table->timestamps(); - }); - - $this->getBuilder()->create('posts_categories', function ($table) { - $table->primary(['post_id', 'category_id']); - $table->unsignedInteger('post_id'); - $table->unsignedInteger('category_id'); - $table->string('data')->nullable(); - $table->timestamps(); - }); - - $this->seedTables(); - } - - protected function seedTables() - { - $this->seeded['posts'][] = Post::create([ - 'title' => 'A Post', - 'published' => true, - 'published_at' => Carbon::now()->sub('minutes', 10), - ]); - $this->seeded['posts'][] = Post::create([ - 'title' => 'A Second Post', - 'published' => false, - 'published_at' => null - ]); - - $this->seeded['categories'][] = Category::create([ - 'name' => 'Category 1' - ]); - $this->seeded['categories'][] = Category::create([ - 'name' => 'Category 2' - ]); - - $this->seeded['labels'][] = Term::create(['type' => 'label', 'name' => 'Announcement']); - $this->seeded['labels'][] = Term::create(['type' => 'label', 'name' => 'News']); - - $this->seeded['posts'][0]->labels()->attach($this->seeded['labels'][0]); - $this->seeded['posts'][0]->categories()->attach($this->seeded['categories'][0]); - $this->seeded['posts'][1]->labels()->attach($this->seeded['labels'][1]); - $this->seeded['posts'][1]->categories()->attach($this->seeded['categories'][1]); - - $this->seeded['tags'][] = $this->seeded['posts'][0]->tags()->create(['type' => 'tag', 'name' => 'A Tag']); - $this->seeded['tags'][] = $this->seeded['posts'][0]->tags()->create(['type' => 'tag', 'name' => 'Second Tag']); - } -} - -class Category extends \Winter\Storm\Database\Model -{ - public $table = 'categories'; - - public $fillable = ['name']; - - protected $dates = [ - 'created_at', - 'updated_at', - ]; - - public $belongsToMany = [ - 'posts' => [ - Post::class, - 'table' => 'posts_categories', - 'order' => 'published_at desc', - 'scope' => 'isPublished' - ] - ]; -} - -class Post extends \Winter\Storm\Database\Model -{ - public $table = 'posts'; - - public $fillable = ['title', 'published', 'published_at']; - - protected $dates = [ - 'created_at', - 'updated_at', - 'published_at', - ]; - - public $belongsToMany = [ - 'categories' => [ - Category::class, - 'table' => 'posts_categories', - 'order' => 'name', - 'pivot' => ['data'], - ], - 'tags' => [ - Term::class, - 'table' => 'posts_terms', - 'key' => 'post_id', - 'otherKey' => 'term_id', - 'pivot' => ['data'], - 'timestamps' => true, - 'conditions' => 'type = "tag"', - ], - 'labels' => [ - Term::class, - 'table' => 'posts_terms', - 'key' => 'post_id', - 'otherKey' => 'term_id', - 'pivot' => ['data'], - 'timestamps' => true, - 'conditions' => 'type = "label"', - ], - 'terms' => [ - Term::class, - 'table' => 'posts_terms', - 'key' => 'post_id', - 'otherKey' => 'term_id', - 'timestamps' => true, - ], - ]; - - public function scopeIsPublished($query) - { - return $query - ->whereNotNull('published') - ->where('published', true) - ->whereNotNull('published_at') - ->where('published_at', '<', Carbon::now()) - ; - } -} - -class Term extends \Winter\Storm\Database\Model -{ - public $table = 'terms'; - - public $fillable = ['type', 'name']; - - protected $dates = [ - 'created_at', - 'updated_at', - 'episode_at' - ]; - - public $belongsToMany = [ - 'posts' => [ - 'Post', - 'table' => 'posts_terms', - 'key' => 'term_id', - 'otherKey' => 'post_id', - 'pivot' => ['data'], - 'timestamps' => true, - 'conditions' => 'type = "post"', - ], - ]; -} - -class Morphs extends \Winter\Storm\Database\Model -{ - public $table = 'morphs'; - - public $morphTo = [ - 'related' => [], - ]; -} diff --git a/tests/Database/Traits/DeferredBindingTest.php b/tests/Database/Traits/DeferredBindingTest.php new file mode 100644 index 000000000..cefe99d84 --- /dev/null +++ b/tests/Database/Traits/DeferredBindingTest.php @@ -0,0 +1,102 @@ + 'Stevie']); + $post = Post::create(['title' => "First post"]); + $post2 = Post::create(['title' => "Second post"]); + Model::reguard(); + + $author->posts()->add($post, $sessionKey); + $this->assertEquals(1, DeferredBinding::count()); + + // Skip repeat bindings + $author->posts()->add($post, $sessionKey); + $this->assertEquals(1, DeferredBinding::count()); + + // Remove add-delete pairs + $author->posts()->remove($post, $sessionKey); + $this->assertEquals(0, DeferredBinding::count()); + + // Multi ball + $sessionKey = uniqid('session_key', true); + $author->posts()->add($post, $sessionKey); + $author->posts()->add($post, $sessionKey); + $author->posts()->add($post, $sessionKey); + $author->posts()->add($post, $sessionKey); + $author->posts()->add($post2, $sessionKey); + $author->posts()->add($post2, $sessionKey); + $author->posts()->add($post2, $sessionKey); + $author->posts()->add($post2, $sessionKey); + $author->posts()->add($post2, $sessionKey); + $this->assertEquals(2, DeferredBinding::count()); + + // Clean up add-delete pairs + $author->posts()->remove($post, $sessionKey); + $author->posts()->remove($post2, $sessionKey); + $this->assertEquals(0, DeferredBinding::count()); + + // Double negative + $author->posts()->remove($post, $sessionKey); + $author->posts()->remove($post2, $sessionKey); + $this->assertEquals(2, DeferredBinding::count()); + + // Skip repeat bindings + $author->posts()->remove($post, $sessionKey); + $author->posts()->remove($post2, $sessionKey); + $this->assertEquals(2, DeferredBinding::count()); + + // Clean up add-delete pairs again + $author->posts()->add($post, $sessionKey); + $author->posts()->add($post2, $sessionKey); + $this->assertEquals(0, DeferredBinding::count()); + } + + public function testCancelBinding() + { + $sessionKey = uniqid('session_key', true); + DeferredBinding::truncate(); + + Model::unguard(); + $author = Author::make(['name' => 'Stevie']); + $post = Post::create(['title' => "First post"]); + Model::reguard(); + + $author->posts()->add($post, $sessionKey); + $this->assertEquals(1, DeferredBinding::count()); + + $author->cancelDeferred($sessionKey); + $this->assertEquals(0, DeferredBinding::count()); + } + + public function testCommitBinding() + { + $sessionKey = uniqid('session_key', true); + DeferredBinding::truncate(); + + Model::unguard(); + $author = Author::make(['name' => 'Stevie']); + $post = Post::create(['title' => "First post"]); + Model::reguard(); + + $author->posts()->add($post, $sessionKey); + $this->assertEquals(1, DeferredBinding::count()); + + $author->commitDeferred($sessionKey); + $this->assertEquals(0, DeferredBinding::count()); + } +} diff --git a/tests/Database/Traits/NestedTreeTest.php b/tests/Database/Traits/NestedTreeTest.php new file mode 100644 index 000000000..984d18e6d --- /dev/null +++ b/tests/Database/Traits/NestedTreeTest.php @@ -0,0 +1,154 @@ +seedSampleTree(); + } + + public function testGetNested() + { + $items = CategoryNested::getNested(); + + // Eager loaded + $items->each(function ($item) { + $this->assertTrue($item->relationLoaded('children')); + }); + + $this->assertEquals(2, $items->count()); + } + + public function testGetAllRoot() + { + $items = CategoryNested::getAllRoot(); + + // Not eager loaded + $items->each(function ($item) { + $this->assertFalse($item->relationLoaded('children')); + }); + + $this->assertEquals(2, $items->count()); + } + + public function testListsNested() + { + $array = CategoryNested::listsNested('name', 'id'); + $this->assertEquals([ + 1 => 'Category Orange', + 2 => '   Autumn Leaves', + 3 => '      September', + 4 => '      October', + 5 => '      November', + 6 => '   Summer Breeze', + 7 => 'Category Green', + 8 => '   Winter Snow', + 9 => '   Spring Trees' + ], $array); + + CategoryNested::flushDuplicateCache(); + + $array = CategoryNested::listsNested('name', 'id', '--'); + $this->assertEquals([ + 1 => 'Category Orange', + 2 => '--Autumn Leaves', + 3 => '----September', + 4 => '----October', + 5 => '----November', + 6 => '--Summer Breeze', + 7 => 'Category Green', + 8 => '--Winter Snow', + 9 => '--Spring Trees' + ], $array); + + CategoryNested::flushDuplicateCache(); + + $array = CategoryNested::listsNested('description', 'name', '**'); + $this->assertEquals([ + 'Category Orange' => 'A root level test category', + 'Autumn Leaves' => '**Disccusion about the season of falling leaves.', + 'September' => '****The start of the fall season.', + 'October' => '****The middle of the fall season.', + 'November' => '****The end of the fall season.', + 'Summer Breeze' => '**Disccusion about the wind at the ocean.', + 'Category Green' => 'A root level test category', + 'Winter Snow' => '**Disccusion about the frosty snow flakes.', + 'Spring Trees' => '**Disccusion about the blooming gardens.' + ], $array); + } + + public function testListsNestedFromCollection() + { + $array = CategoryNested::get()->listsNested('custom_name', 'id', '...'); + $this->assertEquals([ + 1 => 'Category Orange (#1)', + 2 => '...Autumn Leaves (#2)', + 3 => '......September (#3)', + 4 => '......October (#4)', + 5 => '......November (#5)', + 6 => '...Summer Breeze (#6)', + 7 => 'Category Green (#7)', + 8 => '...Winter Snow (#8)', + 9 => '...Spring Trees (#9)' + ], $array); + } + + public function seedSampleTree() + { + Model::unguard(); + + $orange = CategoryNested::create([ + 'name' => 'Category Orange', + 'description' => 'A root level test category', + ]); + + $autumn = $orange->children()->create([ + 'name' => 'Autumn Leaves', + 'description' => 'Disccusion about the season of falling leaves.' + ]); + + $autumn->children()->create([ + 'name' => 'September', + 'description' => 'The start of the fall season.' + ]); + + $autumn->children()->create([ + 'name' => 'October', + 'description' => 'The middle of the fall season.' + ]); + + $autumn->children()->create([ + 'name' => 'November', + 'description' => 'The end of the fall season.' + ]); + + $orange->children()->create([ + 'name' => 'Summer Breeze', + 'description' => 'Disccusion about the wind at the ocean.' + ]); + + $green = CategoryNested::create([ + 'name' => 'Category Green', + 'description' => 'A root level test category', + ]); + + $green->children()->create([ + 'name' => 'Winter Snow', + 'description' => 'Disccusion about the frosty snow flakes.' + ]); + + $green->children()->create([ + 'name' => 'Spring Trees', + 'description' => 'Disccusion about the blooming gardens.' + ]); + + Model::reguard(); + } +} diff --git a/tests/Database/Traits/NullableTest.php b/tests/Database/Traits/NullableTest.php new file mode 100644 index 000000000..938ca028d --- /dev/null +++ b/tests/Database/Traits/NullableTest.php @@ -0,0 +1,52 @@ + ''])->reload(); + $this->assertEquals('Winter', $post->author_nickname); + + // Save as empty string + $post->author_nickname = ''; + $post->save(); + $this->assertNull($post->author_nickname); + } + + public function testNonEmptyValuesAreIgnored() + { + // Save as value + $post = NullablePost::create(['author_nickname' => 'Joe']); + $this->assertEquals('Joe', $post->author_nickname); + + // Save as zero integer + $post->author_nickname = 0; + $post->save(); + $this->assertNotNull($post->author_nickname); + $this->assertEquals(0, $post->author_nickname); + + // Save as zero float + $post->author_nickname = 0.0; + $post->save(); + $this->assertNotNull($post->author_nickname); + $this->assertEquals(0.0, $post->author_nickname); + + // Save as zero string + $post->author_nickname = '0'; + $post->save(); + $this->assertNotNull($post->author_nickname); + $this->assertEquals('0', $post->author_nickname); + + // Save as false + $post->author_nickname = false; + $post->save(); + $this->assertNotNull($post->author_nickname); + $this->assertEquals(false, $post->author_nickname); + } +} diff --git a/tests/Database/Traits/RevisionableTest.php b/tests/Database/Traits/RevisionableTest.php new file mode 100644 index 000000000..f90fbe5e8 --- /dev/null +++ b/tests/Database/Traits/RevisionableTest.php @@ -0,0 +1,135 @@ + 'Hello World!']); + $this->assertEquals('Hello World!', $post->title); + $this->assertEquals(0, $post->revision_history()->count()); + + $post->revisionsEnabled = false; + $post->title = 'Helloooooooooooo!'; + $post->save(); + $this->assertEquals(0, $post->revision_history()->count()); + } + + public function testUpdateSingleField() + { + $post = RevisionablePost::create(['title' => 'Hello World!']); + $this->assertEquals('Hello World!', $post->title); + $this->assertEquals(0, $post->revision_history()->count()); + + $post->title = 'Helloooooooooooo!'; + $post->save(); + $this->assertEquals(1, $post->revision_history()->count()); + + $item = $post->revision_history()->first(); + + $this->assertEquals('title', $item->field); + $this->assertEquals('Hello World!', $item->old_value); + $this->assertEquals('Helloooooooooooo!', $item->new_value); + $this->assertEquals(7, $item->user_id); + } + + public function testUpdateMultipleFields() + { + $post = RevisionablePost::create([ + 'title' => 'Hello World!', + 'slug' => 'hello-world', + 'description' => 'Good day, Commander', + 'is_published' => true, + 'published_at' => new \DateTime + ]); + + $this->assertEquals(0, $post->revision_history()->count()); + + $post->title = "Gday mate"; + $post->slug = "gday-mate"; + $post->description = 'Wazzaaaaaaaaaaaaap'; + $post->is_published = false; + $post->published_at = Carbon::now()->addDays(1); + $post->save(); + + $history = $post->revision_history; + + $this->assertEquals(5, $history->count()); + $this->assertEquals([ + 'title', + 'slug', + 'description', + 'is_published', + 'published_at' + ], $history->lists('field')); + } + + public function testExceedingRevisionLimit() + { + $post = RevisionablePost::create([ + 'title' => 'Hello World!', + 'slug' => 'hello-world', + 'description' => 'Good day, Commander', + 'is_published' => true, + 'published_at' => new \DateTime + ]); + + $this->assertEquals(0, $post->revision_history()->count()); + + $post->title = "Gday mate"; + $post->slug = "gday-mate"; + $post->description = 'Wazzaaaaaaaaaaaaap'; + $post->is_published = false; + $post->published_at = Carbon::now()->addDays(1); + $post->save(); + + $post->title = 'The Boss'; + $post->slug = 'the-boss'; + $post->description = 'Paid the cost to be the boss'; + $post->is_published = true; + $post->published_at = Carbon::now()->addDays(10); + $post->save(); + + // Count 10 changes above, limit is 8 + $this->assertEquals(8, $post->revision_history()->count()); + } + + public function testSoftDeletes() + { + $post = RevisionablePost::create(['title' => 'Hello World!']); + $this->assertEquals('Hello World!', $post->title); + $this->assertEquals(0, $post->revision_history()->count()); + + $post->title = 'Helloooooooooooo!'; + $post->save(); + $this->assertEquals(1, $post->revision_history()->count()); + + $post->delete(); + $this->assertEquals(2, $post->revision_history()->count()); + } + + public function testRevisionDateCast() + { + $post = RevisionablePost::create([ + 'title' => 'Hello World!', + 'published_at' => Carbon::now() + ]); + $this->assertEquals(0, $post->revision_history()->count()); + + $post->published_at = Carbon::now()->addDays(1); + $post->save(); + + $this->assertEquals(1, $post->revision_history()->count()); + + $item = $post->revision_history()->first(); + $this->assertEquals('published_at', $item->field); + $this->assertEquals('date', $item->cast); + $this->assertInstanceOf('Carbon\Carbon', $item->old_value); + $this->assertInstanceOf('Carbon\Carbon', $item->new_value); + } +} diff --git a/tests/Database/Traits/SimpleTreeTest.php b/tests/Database/Traits/SimpleTreeTest.php new file mode 100644 index 000000000..2bf877c3c --- /dev/null +++ b/tests/Database/Traits/SimpleTreeTest.php @@ -0,0 +1,240 @@ +seedSampleTree(); + } + + public function testGetNested() + { + $items = CategorySimple::getNested(); + + // Eager loaded + $items->each(function ($item) { + $this->assertTrue($item->relationLoaded('children')); + }); + + $this->assertEquals(3, $items->count()); + } + + public function testGetAllRoot() + { + $items = CategorySimple::getAllRoot(); + + // Not eager loaded + $items->each(function ($item) { + $this->assertFalse($item->relationLoaded('children')); + }); + + $this->assertEquals(3, $items->count()); + } + + public function testGetChildren() + { + // Not eager loaded + $item = CategorySimple::first(); + $this->assertFalse($item->relationLoaded('children')); + $this->assertEquals(6, $item->getChildren()->count()); + + // Not eager loaded + $item = CategorySimple::getAllRoot()->first(); + $this->assertFalse($item->relationLoaded('children')); + $this->assertEquals(6, $item->getChildren()->count()); + + // Eager loaded + $item = CategorySimple::getNested()->first(); + $this->assertTrue($item->relationLoaded('children')); + $this->assertEquals(6, $item->getChildren()->count()); + } + + public function testGetChildCount() + { + // Not eager loaded + $item = CategorySimple::first(); + $this->assertFalse($item->relationLoaded('children')); + $this->assertEquals(9, $item->getChildCount()); + + // Not eager loaded + $item = CategorySimple::getAllRoot()->first(); + $this->assertFalse($item->relationLoaded('children')); + $this->assertEquals(9, $item->getChildCount()); + + // Eager loaded + $item = CategorySimple::getNested()->first(); + $this->assertTrue($item->relationLoaded('children')); + $this->assertEquals(9, $item->getChildCount()); + } + + public function testGetAllChildren() + { + // Not eager loaded + $item = CategorySimple::first(); + $this->assertFalse($item->relationLoaded('children')); + $this->assertEquals(9, $item->getAllChildren()->count()); + + // Not eager loaded + $item = CategorySimple::getAllRoot()->first(); + $this->assertFalse($item->relationLoaded('children')); + $this->assertEquals(9, $item->getAllChildren()->count()); + + // Eager loaded + $item = CategorySimple::getNested()->first(); + $this->assertTrue($item->relationLoaded('children')); + $this->assertEquals(9, $item->getAllChildren()->count()); + } + + public function testListsNested() + { + $array = CategorySimple::listsNested('name', 'id'); + $this->assertEquals([ + 1 => 'Web development', + 2 => '   HTML5', + 3 => '   CSS3', + 4 => '   jQuery', + 5 => '   Bootstrap', + 6 => '   Laravel', + 7 => '   Winter CMS', + 8 => '      September', + 9 => '      October', + 10 => '      November', + 11 => 'Mobile development', + 12 => '   iOS', + 13 => '   iPhone', + 14 => '   iPad', + 15 => '   Android', + 16 => 'Graphic design', + 17 => '   Photoshop', + 18 => '   Illustrator', + 19 => '   Fireworks' + ], $array); + + $array = CategorySimple::listsNested('name', 'id', '--'); + $this->assertEquals([ + 1 => 'Web development', + 2 => '--HTML5', + 3 => '--CSS3', + 4 => '--jQuery', + 5 => '--Bootstrap', + 6 => '--Laravel', + 7 => '--Winter CMS', + 8 => '----September', + 9 => '----October', + 10 => '----November', + 11 => 'Mobile development', + 12 => '--iOS', + 13 => '--iPhone', + 14 => '--iPad', + 15 => '--Android', + 16 => 'Graphic design', + 17 => '--Photoshop', + 18 => '--Illustrator', + 19 => '--Fireworks' + ], $array); + + $array = CategorySimple::listsNested('id', 'name', '**'); + $this->assertEquals([ + 'Web development' => '1', + 'HTML5' => '**2', + 'CSS3' => '**3', + 'jQuery' => '**4', + 'Bootstrap' => '**5', + 'Laravel' => '**6', + 'Winter CMS' => '**7', + 'September' => '****8', + 'October' => '****9', + 'November' => '****10', + 'Mobile development' => '11', + 'iOS' => '**12', + 'iPhone' => '**13', + 'iPad' => '**14', + 'Android' => '**15', + 'Graphic design' => '16', + 'Photoshop' => '**17', + 'Illustrator' => '**18', + 'Fireworks' => '**19' + ], $array); + } + + public function testListsNestedUnknownColumn() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Column mismatch in listsNested method'); + + CategorySimple::listsNested('custom_name', 'id'); + } + + public function testListsNestedFromCollection() + { + $array = CategorySimple::get()->listsNested('custom_name', 'id', '...'); + + $this->assertEquals([ + 1 => 'Web development (#1)', + 2 => '...HTML5 (#2)', + 3 => '...CSS3 (#3)', + 4 => '...jQuery (#4)', + 5 => '...Bootstrap (#5)', + 6 => '...Laravel (#6)', + 7 => '...Winter CMS (#7)', + 8 => '......September (#8)', + 9 => '......October (#9)', + 10 => '......November (#10)', + 11 => 'Mobile development (#11)', + 12 => '...iOS (#12)', + 13 => '...iPhone (#13)', + 14 => '...iPad (#14)', + 15 => '...Android (#15)', + 16 => 'Graphic design (#16)', + 17 => '...Photoshop (#17)', + 18 => '...Illustrator (#18)', + 19 => '...Fireworks (#19)' + ], $array); + } + + + public function seedSampleTree() + { + Model::unguard(); + + $webdev = CategorySimple::create([ + 'name' => 'Web development' + ]); + + $webdev->children()->create(['name' => 'HTML5']); + $webdev->children()->create(['name' => 'CSS3']); + $webdev->children()->create(['name' => 'jQuery']); + $webdev->children()->create(['name' => 'Bootstrap']); + $webdev->children()->create(['name' => 'Laravel']); + $winter = $webdev->children()->create(['name' => 'Winter CMS']); + $winter->children()->create(['name' => 'September']); + $winter->children()->create(['name' => 'October']); + $winter->children()->create(['name' => 'November']); + + $mobdev = CategorySimple::create([ + 'name' => 'Mobile development' + ]); + + $mobdev->children()->create(['name' => 'iOS']); + $mobdev->children()->create(['name' => 'iPhone']); + $mobdev->children()->create(['name' => 'iPad']); + $mobdev->children()->create(['name' => 'Android']); + + $design = CategorySimple::create([ + 'name' => 'Graphic design' + ]); + + $design->children()->create(['name' => 'Photoshop']); + $design->children()->create(['name' => 'Illustrator']); + $design->children()->create(['name' => 'Fireworks']); + + Model::reguard(); + } +} diff --git a/tests/Database/Traits/SluggableTest.php b/tests/Database/Traits/SluggableTest.php index 5ed0b67c8..f4934df8b 100644 --- a/tests/Database/Traits/SluggableTest.php +++ b/tests/Database/Traits/SluggableTest.php @@ -1,5 +1,10 @@ createTables(); } - public function testSlugGeneration() + public function testFillPost() { - /* - * Basic usage of slug Generator - */ - $testModel1 = TestModelSluggable::Create(['name' => 'test']); - $this->assertEquals($testModel1->slug, 'test'); + $post = SluggablePost::create(['title' => 'Hello World!']); + $this->assertEquals('hello-world', $post->slug); + } - $testModel2 = TestModelSluggable::Create(['name' => 'test']); - $this->assertEquals($testModel2->slug, 'test-2'); + public function testSetAttributeOnPost() + { + $post = new SluggablePost; + $post->title = "Let's go, rock show!"; + $post->save(); - $testModel3 = TestModelSluggable::Create(['name' => 'test']); - $this->assertEquals($testModel3->slug, 'test-3'); + $this->assertEquals('lets-go-rock-show', $post->slug); } - public function testSlugGenerationSoftDelete() + public function testSetSlugAttributeManually() { - /* - * Basic usage of slug Generator with softDelete - */ - $testSoftModel1 = TestModelSluggableSoftDelete::Create(['name' => 'test']); - $this->assertEquals($testSoftModel1->slug, 'test'); + $post = new SluggablePost; + $post->title = 'We parked in a comfortable spot'; + $post->slug = 'war-is-pain'; + $post->save(); - $testSoftModel2 = TestModelSluggableSoftDelete::Create(['name' => 'test']); - $this->assertEquals($testSoftModel2->slug, 'test-2'); + $this->assertEquals('war-is-pain', $post->slug); + } + + public function testConcatenatedSlug() + { + $post = new SluggablePost; + $post->title = 'Sweetness and Light'; + $post->description = 'Itchee and Scratchee'; + $post->save(); - $testSoftModel3 = TestModelSluggableSoftDelete::Create(['name' => 'test']); - $this->assertEquals($testSoftModel3->slug, 'test-3'); + $this->assertEquals('sweetness-and-light-itchee-and-scratchee', $post->long_slug); } - public function testSlugGenerationSoftDeleteAllow() + public function testDuplicateSlug() { - /* - * Basic usage of slug Generator with softDelete - * And allowTrashedSlugs - */ - $testSoftModelAllow1 = TestModelSluggableSoftDeleteAllow::Create(['name' => 'test']); - $this->assertEquals($testSoftModelAllow1->slug, 'test'); + $post1 = SluggablePost::create(['title' => 'Pace yourself']); + $post2 = SluggablePost::create(['title' => 'Pace yourself']); + $post3 = SluggablePost::create(['title' => 'Pace yourself']); - $testSoftModelAllow2 = TestModelSluggableSoftDeleteAllow::Create(['name' => 'test']); - $this->assertEquals($testSoftModelAllow2->slug, 'test-2'); + $this->assertEquals('pace-yourself', $post1->slug); + $this->assertEquals('pace-yourself-2', $post2->slug); + $this->assertEquals('pace-yourself-3', $post3->slug); + } - $testSoftModelAllow3 = TestModelSluggableSoftDeleteAllow::Create(['name' => 'test']); - $this->assertEquals($testSoftModelAllow3->slug, 'test-3'); + public function testCollisionWithSelf() + { + $post1 = SluggablePost::create(['title' => 'Watch yourself']); + $post2 = SluggablePost::create(['title' => 'Watch yourself']); + $post3 = SluggablePost::create(['title' => 'Watch yourself']); + + $this->assertEquals('watch-yourself', $post1->slug); + $this->assertEquals('watch-yourself-2', $post2->slug); + $this->assertEquals('watch-yourself-3', $post3->slug); + + $post3->slugAttributes(); + $post3->save(); + $post2->slugAttributes(); + $post2->save(); + $post1->slugAttributes(); + $post1->save(); + + $this->assertEquals('watch-yourself', $post1->slug); + $this->assertEquals('watch-yourself-2', $post2->slug); + $this->assertEquals('watch-yourself-3', $post3->slug); + } + + public function testSuffixCollision() + { + $post1 = SluggablePost::create(['title' => 'Type 1']); + $post2 = SluggablePost::create(['title' => 'Type 2']); + $post3 = SluggablePost::create(['title' => 'Type 3']); + $post4 = SluggablePost::create(['title' => 'Type 3']); + $post5 = SluggablePost::create(['title' => 'Type 3']); + + $this->assertEquals('type-1', $post1->slug); + $this->assertEquals('type-2', $post2->slug); + $this->assertEquals('type-3', $post3->slug); + $this->assertEquals('type-3-2', $post4->slug); + $this->assertEquals('type-3-3', $post5->slug); } public function testSlugGenerationWithSoftDeletion() diff --git a/tests/Database/Traits/SoftDeleteTest.php b/tests/Database/Traits/SoftDeleteTest.php index 258b8208b..3ed4d8dd1 100644 --- a/tests/Database/Traits/SoftDeleteTest.php +++ b/tests/Database/Traits/SoftDeleteTest.php @@ -2,130 +2,195 @@ namespace Winter\Storm\Tests\Database\Traits; -class SoftDeleteTest extends \DbTestCase +use Winter\Storm\Tests\Database\Fixtures\Author; +use Winter\Storm\Tests\Database\Fixtures\UserWithAuthor; +use Winter\Storm\Tests\Database\Fixtures\SoftDeleteAuthor; +use Winter\Storm\Tests\Database\Fixtures\UserWithSoftAuthor; +use Winter\Storm\Tests\Database\Fixtures\UserWithAuthorAndSoftDelete; +use Winter\Storm\Tests\Database\Fixtures\UserWithSoftAuthorAndSoftDelete; +use Winter\Storm\Database\Model; +use Winter\Storm\Tests\Database\Fixtures\Category; +use Winter\Storm\Tests\Database\Fixtures\EventLog; +use Winter\Storm\Tests\Database\Fixtures\Post; +use Winter\Storm\Tests\Database\Fixtures\UserLaravel; +use Winter\Storm\Tests\Database\Fixtures\UserLaravelWithSoftAuthor; +use Winter\Storm\Tests\Database\Fixtures\UserLaravelWithSoftAuthorAndSoftDelete; +use Winter\Storm\Tests\Database\Fixtures\UserLaravelWithSoftDelete; +use Winter\Storm\Tests\DbTestCase; + +class SoftDeleteTest extends DbTestCase { - protected $seeded = []; + public function testDeleteOptionOnHardModel() + { + Model::unguard(); + $user = UserWithAuthor::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = Author::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Hard + $this->assertNull(Author::find($authorId)); + } - public function setUp(): void + public function testDeleteOptionOnHardModelLaravelRelation() { - parent::setUp(); + Model::unguard(); + $user = UserLaravel::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = Author::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Hard + $this->assertNull(Author::find($authorId)); + } - $this->seeded = [ - 'posts' => [], - 'categories' => [] - ]; + public function testSoftDeleteOptionOnHardModel() + { + Model::unguard(); + $user = UserWithSoftAuthor::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = Author::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Hard + $this->assertNotNull(Author::find($authorId)); // Do nothing + } - $this->createTables(); - $this->seedTables(); + public function testSoftDeleteOptionOnHardModelLaravelRelation() + { + Model::unguard(); + $user = UserLaravelWithSoftAuthor::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = Author::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Hard + $this->assertNotNull(Author::find($authorId)); // Do nothing } - protected function createTables() + public function testSoftDeleteOptionOnSoftModel() { - $this->getBuilder()->create('posts', function ($table) { - $table->increments('id'); - $table->string('title')->default(''); - $table->timestamps(); - $table->timestamp('deleted_at')->nullable(); - }); - - $this->getBuilder()->create('categories', function ($table) { - $table->increments('id'); - $table->string('name'); - $table->timestamps(); - }); - - $this->getBuilder()->create('categories_posts', function ($table) { - $table->primary(['post_id', 'category_id']); - $table->unsignedInteger('post_id'); - $table->unsignedInteger('category_id'); - $table->timestamp('deleted_at')->nullable(); - }); + Model::unguard(); + $user = UserWithSoftAuthorAndSoftDelete::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = SoftDeleteAuthor::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Soft + $this->assertNull(SoftDeleteAuthor::find($authorId)); + $this->assertNotNull(SoftDeleteAuthor::withTrashed()->find($authorId)); } - protected function seedTables() + public function testSoftDeleteOptionOnSoftModelLaravelRelation() { - $this->seeded['posts'][] = Post::create([ - 'title' => 'First Post', - ]); - $this->seeded['posts'][] = Post::create([ - 'title' => 'Second Post', - ]); - - $this->seeded['categories'][] = Category::create([ - 'name' => 'Category 1' - ]); - $this->seeded['categories'][] = Category::create([ - 'name' => 'Category 2' - ]); - - $this->seeded['posts'][0]->categories()->attach($this->seeded['categories'][0]); - $this->seeded['posts'][0]->categories()->attach($this->seeded['categories'][1]); - - $this->seeded['posts'][1]->categories()->attach($this->seeded['categories'][0]); - $this->seeded['posts'][1]->categories()->attach($this->seeded['categories'][1]); + Model::unguard(); + $user = UserLaravelWithSoftAuthorAndSoftDelete::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = SoftDeleteAuthor::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Soft + $this->assertNull(SoftDeleteAuthor::find($authorId)); + $this->assertNotNull(SoftDeleteAuthor::withTrashed()->find($authorId)); } - public function testDeleteAndRestore() + public function testDeleteOptionOnSoftModel() { - $post = Post::first(); - $this->assertTrue($post->deleted_at === null); - $this->assertTrue($post->categories()->where('deleted_at', null)->count() === 2); + Model::unguard(); + $user = UserWithAuthorAndSoftDelete::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = Author::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); - $post->delete(); + $authorId = $author->id; + $user->delete(); // Soft + $this->assertNotNull(Author::find($authorId)); // Do nothing - $post = Post::withTrashed()->first(); - $this->assertTrue($post->deleted_at != null); - $this->assertTrue($post->categories()->where('deleted_at', '!=', null)->count() === 2); - $post->restore(); + $userId = $user->id; + $user = UserWithAuthorAndSoftDelete::withTrashed()->find($userId); + $user->restore(); - $post = Post::first(); - $this->assertTrue($post->deleted_at === null); - $this->assertTrue($post->categories()->where('deleted_at', null)->count() === 2); + $user->forceDelete(); // Hard + $this->assertNull(Author::find($authorId)); } -} -class Post extends \Winter\Storm\Database\Model -{ - use \Winter\Storm\Database\Traits\SoftDelete; - - public $table = 'posts'; - - public $fillable = ['title']; - - protected $dates = [ - 'created_at', - 'updated_at', - 'deleted_at', - ]; - - public $belongsToMany = [ - 'categories' => [ - Category::class, - 'table' => 'categories_posts', - 'key' => 'post_id', - 'otherKey' => 'category_id', - 'softDelete' => true, - ], - ]; -} + public function testDeleteOptionOnSoftModelLaravelRelation() + { + Model::unguard(); + $user = UserLaravelWithSoftDelete::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = Author::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); -class Category extends \Winter\Storm\Database\Model -{ - public $table = 'categories'; - - public $fillable = ['name']; - - protected $dates = [ - 'created_at', - 'updated_at', - ]; - - public $belongsToMany = [ - 'posts' => [ - Post::class, - 'table' => 'categories_posts', - 'key' => 'category_id', - 'otherKey' => 'post_id', - ], - ]; + $authorId = $author->id; + $user->delete(); // Soft + $this->assertNotNull(Author::find($authorId)); // Do nothing + + $userId = $user->id; + $user = UserLaravelWithSoftDelete::withTrashed()->find($userId); + $user->restore(); + + $user->forceDelete(); // Hard + $this->assertNull(Author::find($authorId)); + } + + public function testRestoreSoftDeleteRelation() + { + Model::unguard(); + $user = UserWithSoftAuthorAndSoftDelete::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = SoftDeleteAuthor::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Soft + $this->assertNull(SoftDeleteAuthor::find($authorId)); + $this->assertNotNull(SoftDeleteAuthor::withTrashed()->find($authorId)); + + $userId = $user->id; + $user = UserWithSoftAuthorAndSoftDelete::withTrashed()->find($userId); + $user->restore(); + + $this->assertNotNull(SoftDeleteAuthor::find($authorId)); + } + + public function testRestoreSoftDeleteRelationLaravelRelation() + { + Model::unguard(); + $user = UserLaravelWithSoftAuthorAndSoftDelete::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = SoftDeleteAuthor::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Soft + $this->assertNull(SoftDeleteAuthor::find($authorId)); + $this->assertNotNull(SoftDeleteAuthor::withTrashed()->find($authorId)); + + $userId = $user->id; + $user = UserLaravelWithSoftAuthorAndSoftDelete::withTrashed()->find($userId); + $user->restore(); + + $this->assertNotNull(SoftDeleteAuthor::find($authorId)); + } + + public function testCannotMakeModelSoftDeleteIfNotUsingTrait() + { + $categoryModel = new Category(); + $relation = $categoryModel->hasMany(Post::class); + + $this->assertFalse($relation->isSoftDeletable()); + + $relation->softDeletable(); + + // Should still be false because the model does not use the trait + $this->assertFalse($relation->isSoftDeletable()); + + $postModel = new Post(); + + $relation = $postModel->morphMany(EventLog::class, 'related'); + + $this->assertFalse($relation->isSoftDeletable()); + + $relation->softDeletable(); + + // This should now be true because EventLog does use the trait + $this->assertTrue($relation->isSoftDeletable()); + } } diff --git a/tests/Database/SortableTest.php b/tests/Database/Traits/SortableTest.php similarity index 95% rename from tests/Database/SortableTest.php rename to tests/Database/Traits/SortableTest.php index 906522ee9..7ab0c07b7 100644 --- a/tests/Database/SortableTest.php +++ b/tests/Database/Traits/SortableTest.php @@ -1,5 +1,9 @@ 'sqlite', - 'database' => ':memory:', - ]; - App::make(ConnectionFactory::class)->make($config, 'testing'); - DB::setDefaultConnection('testing'); - - Model::setEventDispatcher(new Dispatcher()); + Model::setEventDispatcher($this->modelDispatcher()); } public function tearDown(): void { $this->flushModelEventListeners(); + $this->rollbackModels(); parent::tearDown(); } + /** + * Creates a dispatcher for the model events. + */ + protected function modelDispatcher(): Dispatcher + { + $dispatcher = new Dispatcher(); + + $callback = function ($eventName, $params) { + if (!str_starts_with($eventName, 'eloquent.booted')) { + return; + } + + $model = $params[0]; + + if (!in_array('Winter\Storm\Tests\Database\Fixtures\MigratesForTesting', class_uses_recursive($model))) { + return; + } + + if ($model::$migrated === false) { + $model::migrateUp($this->getBuilder()); + $model::$migrated = true; + $this->migratedModels[] = $model; + } + }; + + $dispatcher->listen('*', $callback); + + return $dispatcher; + } + /** * Returns an instance of the schema builder for the test database. - * - * @return \Illuminate\Database\Schema\Builder */ - protected function getBuilder() + protected function getBuilder(): Builder { return DB::connection()->getSchemaBuilder(); } + /** + * Rolls back all migrated models. + * + * This should be fired in the teardown process. + */ + protected function rollbackModels(): void + { + foreach ($this->migratedModels as $model) { + $model::migrateDown($this->getBuilder()); + $model::$migrated = false; + } + + $this->migratedModels = []; + } + /** * The models in Winter use a static property to store their events, these * will need to be targeted and reset ready for a new test cycle. @@ -69,4 +124,9 @@ protected function flushModelEventListeners() Model::flushEventListeners(); } + + protected function defineDatabaseMigrations() + { + $this->loadMigrationsFrom(dirname(__DIR__) . '/src/Database/Migrations'); + } } diff --git a/tests/FilesystemAdapterTest.php b/tests/FilesystemAdapterTest.php deleted file mode 100644 index 8dbe8ec8a..000000000 --- a/tests/FilesystemAdapterTest.php +++ /dev/null @@ -1,17 +0,0 @@ -expectException(RuntimeException::class); - - $adapter = new LocalFilesystemAdapter('/tmp/app'); - (new FilesystemAdapter(new Flysystem($adapter), $adapter)) - ->temporaryUrl('test.jpg', \Carbon\Carbon::now()->addMinutes(5)); - } -} diff --git a/tests/TestCase.php b/tests/TestCase.php index 6a59eba7d..53a58f40b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,5 +1,8 @@