diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 83d2599..da8b5e4 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -10,10 +10,10 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - laravel: ['^11.0'] + laravel: ['^12.0'] include: - - laravel: '^11.0' - testbench: '^9.0' + - laravel: '^12.0' + testbench: '^10.0' name: Test coverage (Scrutinizer) diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 611344a..1a13177 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -13,7 +13,7 @@ jobs: - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v2.1.0 + uses: dependabot/fetch-metadata@v2.4.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 43e8e33..a17c471 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -16,11 +16,11 @@ jobs: strategy: matrix: arangodb: ['3.11', '3.12'] - php: ['8.2', '8.3'] - laravel: ['^11.0'] + php: ['8.2', '8.3', '8.4'] + laravel: ['^12.0'] include: - - laravel: '^11.0' - testbench: '^9.0' + - laravel: '^12.0' + testbench: '^10.0' name: QA L ${{ matrix.laravel }} / P ${{ matrix.php }} / A ${{ matrix.arangodb }} - ${{ matrix.dependency-version }} diff --git a/TestSetup/Database/Migrations/2019_11_15_000000_create_characters_table.php b/TestSetup/Database/Migrations/2019_11_15_000000_create_characters_table.php index 986926f..904c178 100644 --- a/TestSetup/Database/Migrations/2019_11_15_000000_create_characters_table.php +++ b/TestSetup/Database/Migrations/2019_11_15_000000_create_characters_table.php @@ -6,7 +6,7 @@ use LaravelFreelancerNL\Aranguent\Facades\Schema; use LaravelFreelancerNL\Aranguent\Schema\Blueprint; -return new class () extends Migration { +return new class extends Migration { /** * Run the migrations. * @@ -14,7 +14,22 @@ */ public function up() { - Schema::create('characters', function (Blueprint $collection) {}); + Schema::create( + 'characters', + function (Blueprint $collection) {}, + [ + 'computedValues' => [ + [ + 'name' => 'full_name', + 'expression' => "RETURN CONCAT_SEPARATOR(' ', @doc.name, @doc.surname)", + 'overwrite' => true, + 'computeOn' => ["insert"], + 'failOnWarning' => false, + 'keepNull' => true, + ], + ], + ], + ); } /** diff --git a/TestSetup/Database/Migrations/2019_11_15_000000_create_children_edge_table.php b/TestSetup/Database/Migrations/2019_11_15_000000_create_children_edge_table.php index 21c7b48..1adefa1 100644 --- a/TestSetup/Database/Migrations/2019_11_15_000000_create_children_edge_table.php +++ b/TestSetup/Database/Migrations/2019_11_15_000000_create_children_edge_table.php @@ -6,7 +6,7 @@ use LaravelFreelancerNL\Aranguent\Facades\Schema; use LaravelFreelancerNL\Aranguent\Schema\Blueprint; -return new class () extends Migration { +return new class extends Migration { public const EDGE_COLLECTION = 3; /** diff --git a/TestSetup/Database/Migrations/2019_11_15_000000_create_houses_table.php b/TestSetup/Database/Migrations/2019_11_15_000000_create_houses_table.php index 21c9949..020c698 100644 --- a/TestSetup/Database/Migrations/2019_11_15_000000_create_houses_table.php +++ b/TestSetup/Database/Migrations/2019_11_15_000000_create_houses_table.php @@ -6,7 +6,7 @@ use LaravelFreelancerNL\Aranguent\Facades\Schema; use LaravelFreelancerNL\Aranguent\Schema\Blueprint; -return new class () extends Migration { +return new class extends Migration { /** * Run the migrations. * diff --git a/TestSetup/Database/Migrations/2019_11_15_000000_create_locations_table.php b/TestSetup/Database/Migrations/2019_11_15_000000_create_locations_table.php index 972eadf..7e722ea 100644 --- a/TestSetup/Database/Migrations/2019_11_15_000000_create_locations_table.php +++ b/TestSetup/Database/Migrations/2019_11_15_000000_create_locations_table.php @@ -6,7 +6,7 @@ use LaravelFreelancerNL\Aranguent\Facades\Schema; use LaravelFreelancerNL\Aranguent\Schema\Blueprint; -return new class () extends Migration { +return new class extends Migration { /** * Run the migrations. * diff --git a/TestSetup/Database/Migrations/2019_11_15_000000_create_taggables_table.php b/TestSetup/Database/Migrations/2019_11_15_000000_create_taggables_table.php index 75b9fff..6a023a6 100644 --- a/TestSetup/Database/Migrations/2019_11_15_000000_create_taggables_table.php +++ b/TestSetup/Database/Migrations/2019_11_15_000000_create_taggables_table.php @@ -6,7 +6,7 @@ use LaravelFreelancerNL\Aranguent\Facades\Schema; use LaravelFreelancerNL\Aranguent\Schema\Blueprint; -return new class () extends Migration { +return new class extends Migration { /** * Run the migrations. * @@ -16,7 +16,11 @@ public function up() { Schema::create('taggables', function (Blueprint $collection) { // - }); + }, [ + 'keyOptions' => [ + 'type' => 'padded', + ], + ]); } /** diff --git a/TestSetup/Database/Migrations/2019_11_15_000000_create_tags_table.php b/TestSetup/Database/Migrations/2019_11_15_000000_create_tags_table.php index fd1c6ce..f59fd70 100644 --- a/TestSetup/Database/Migrations/2019_11_15_000000_create_tags_table.php +++ b/TestSetup/Database/Migrations/2019_11_15_000000_create_tags_table.php @@ -6,7 +6,7 @@ use LaravelFreelancerNL\Aranguent\Facades\Schema; use LaravelFreelancerNL\Aranguent\Schema\Blueprint; -return new class () extends Migration { +return new class extends Migration { /** * Run the migrations. * diff --git a/TestSetup/Database/Migrations/2021_11_22_145621_create_house_view.php b/TestSetup/Database/Migrations/2021_11_22_145621_create_house_view.php index dc29fbb..64ca0c6 100644 --- a/TestSetup/Database/Migrations/2021_11_22_145621_create_house_view.php +++ b/TestSetup/Database/Migrations/2021_11_22_145621_create_house_view.php @@ -5,7 +5,7 @@ use Illuminate\Database\Migrations\Migration; use LaravelFreelancerNL\Aranguent\Facades\Schema; -return new class () extends Migration { +return new class extends Migration { /** * Run the migrations. * diff --git a/TestSetup/Database/Migrations/2024_01_04_145621_create_house_search_alias_view.php b/TestSetup/Database/Migrations/2024_01_04_145621_create_house_search_alias_view.php index e306ba6..3fa21e5 100644 --- a/TestSetup/Database/Migrations/2024_01_04_145621_create_house_search_alias_view.php +++ b/TestSetup/Database/Migrations/2024_01_04_145621_create_house_search_alias_view.php @@ -6,7 +6,7 @@ use LaravelFreelancerNL\Aranguent\Facades\Schema; use LaravelFreelancerNL\Aranguent\Schema\Blueprint; -return new class () extends Migration { +return new class extends Migration { /** * Run the migrations. * diff --git a/TestSetup/Database/Migrations/2024_08_05_145621_create_events_table.php b/TestSetup/Database/Migrations/2024_08_05_145621_create_events_table.php new file mode 100644 index 0000000..58c1c92 --- /dev/null +++ b/TestSetup/Database/Migrations/2024_08_05_145621_create_events_table.php @@ -0,0 +1,31 @@ +call(UsersSeeder::class); $this->call(CharactersSeeder::class); $this->call(ChildrenSeeder::class); $this->call(LocationsSeeder::class); $this->call(TagsSeeder::class); $this->call(TaggablesSeeder::class); $this->call(HousesSeeder::class); + $this->call(EventsSeeder::class); } } diff --git a/TestSetup/Database/Seeders/EventsSeeder.php b/TestSetup/Database/Seeders/EventsSeeder.php new file mode 100644 index 0000000..44ca194 --- /dev/null +++ b/TestSetup/Database/Seeders/EventsSeeder.php @@ -0,0 +1,56 @@ + 'Y-m-d\TH:i:s.vp', 'schema' => [ + /* + * @see https://docs.arangodb.com/stable/develop/http-api/collections/#create-a-collection_body_keyOptions_allowUserKeys + */ 'keyOptions' => [ 'allowUserKeys' => true, 'type' => 'traditional', ], + 'key_handling' => [ + 'prioritize_configured_key_type' => false, + 'use_traditional_over_autoincrement' => true, + ], + // Key type prioritization takes place in the following order: + // 1: table config within the migration file (this always takes priority) + // 2: The id column methods such as id() and ...Increments() methods in the migration file + // 3: The configured key type above. + // The order of 2 and 3 can be swapped; in which case the configured key takes priority over column methods. + // These settings are merged, individual keyOptions can be overridden in this way. ], ]; diff --git a/docs/compatibility-list.md b/docs/compatibility-list.md index 4d59674..cc86823 100644 --- a/docs/compatibility-list.md +++ b/docs/compatibility-list.md @@ -58,23 +58,23 @@ groupByRaw ### Unions union / unionAll -#### Unsupported union clauses -Union orders / Union aggregates / Union groupBy - ### Expressions Expression / raw ### Joins -crossJoin / join / joinSub? / leftJoin / leftJoinSub? +crossJoin / join / joinSub / lateralJoin / leftJoin / leftJoinSub #### Unsupported join clauses -rightJoin / rightJoinSub / joinWhere? +rightJoin / rightJoinSub / rightJoinWhere / joinWhere? / leftJoinWhere? ### Where clauses where / orWhere / whereNot / orWhereNot / whereColumn / whereExists whereBetween / whereNotBetween / whereBetweenColumns / whereNotBetweenColumns / whereJsonContains / whereJsonLength / -whereIn / whereNotIn / whereNull / whereNotNull / +whereIn / whereNotIn / +whereLike / orWhereLike / whereNotLike / orWhereNotLike / +whereNone / orWhereNone / whereNot / orWhereNot / +whereNull / whereNotNull / whereDate / whereMonth / whereDay / whereYear / whereTime / whereRaw (use AQL) / whereAll / orWhereAll / whereAny / orWhereAny @@ -100,7 +100,7 @@ limit / offset / take / skip when ### Insert statements -insert / insertOrIgnore / insertUsing / insertGetId +insert / insertOrIgnore / insertUsing / insertOrIgnoreUsing / insertGetId ### Update statements update / updateOrInsert / upsert / @@ -111,7 +111,7 @@ update with join delete / truncate ### Debugging -dd / dump / toSql / ddRawSql / dumpRawSql / toRawSql +dd / dump / toSql / ddRawSql? / dumpRawSql / toRawSql? ## Eloquent The methods listed below are **specific** to Eloquent. @@ -120,17 +120,17 @@ in the chapter above._ ### Model CRUD all / first / firstWhere / firstOr / firstOrFail / -firstOrCreate? / firstOrNew? / -find / findOr / fresh? / refresh? / -create / fill / save / update / updateOrCreate / -upsert / replicate / delete / destroy / truncate / softDeletes / -trashed? / restore? / withTrashed? / forceDelete -isDirty? / isClean / wasChanged / getOriginal / -pruning / query scopes? / saveQuietly / deleteQuietly / -forceDeleteQuietly / restoreQuietly +firstOrCreate / firstOrNew / +find / findOr? / fresh / refresh / +create / createOrFirst / fill? / save / update / updateOrCreate / +upsert / replicate? / delete / destroy / truncate / softDeletes? / +trashed? / restore? / withTrashed? / forceDelete / +isDirty? / isClean? / wasChanged? / getOriginal? / +pruning? / query scopes? / saveQuietly? / deleteQuietly? / +forceDeleteQuietly? / restoreQuietly? #### Model comparison -is / isNot +is? / isNot? ### Relationships - One To One @@ -142,31 +142,36 @@ is / isNot belongsTo / belongsToMany / morphOne / morphTo / morphMany / morphMany / morphedByMany / -ofMany / latestOfMany / oldestOfMany -hasOne / hasMany / hasX / hasOneThrough / hasManyThrough / -throughX / -whereBelongsTo / -as / -withTimestamps / +ofMany? / latestOfMany? / oldestOfMany? / +has / hasOne / hasMany / hasOneThrough? / hasManyThrough? / +through? / whereBelongsTo? / #### Pivot functions -withPivot / -wherePivot / wherePivotIn /wherePivotNotIn / -wherePivotBetween / wherePivotNotBetween / -wherePivotNull / wherePivotNotNull / orderByPivot / -using +as? / withPivot? / +wherePivot? / wherePivotIn? /wherePivotNotIn? / +wherePivotBetween? / wherePivotNotBetween? / +wherePivotNull? / wherePivotNotNull? / orderByPivot? / +using? / withTimestamps? + +enforceMorphMap? / getMorphClass? / getMorphedModel? / resolveRelationUsing? + +#### Querying Relationship Existence +has / orHas / whereHas / orWhereHas / whereRelation / orWhereRelation / +whereMorphRelation / orWhereMorphRelation -enforceMorphMap / getMorphClass / getMorphedModel / resolveRelationUsing +#### Querying Relationship Absence +doesntHave / orDoesntHave / +whereDoesntHave / orWhereDoesntHave / whereDoesntHaveRelation / orWhereDoesntHaveRelation / +whereMorphDoesntHaveRelation / orWhereMorphDoesntHaveRelation -#### Query relationships -has / orHas / whereHas / whereRelation / doesntHave / -whereDoesntHave / whereHasMorph / whereDoesntHaveMorph +#### Querying Morph To Relationships +whereHasMorph / orWhereHasMorph / whereDoesntHaveMorph / orWhereDoesntHaveMorph / whereMorphedTo? / whereNotMorphedTo? #### Aggregating related models -withCount / loadCount / -withSum / loadSum / withExists / morphWithCount /loadMorphCount / +withCount / loadCount? / +withSum? / loadSum? / withExists / morphWithCount? /loadMorphCount? / +loadMorphCount? -loadMorphCount #### Eager loading with / without / withOnly / constrain / load / loadMissing / loadMorph / preventLazyLoading @@ -180,14 +185,14 @@ updateExistingPivot / ## Artisan commands The following database-related artisan commands are supported: -make:model / db / db:wipe / -make:migration / migrate:install / migrate / +db / db:monitor / db:show / db:table / db:wipe / +make:migration / make:model / migrate:install / migrate / migrate:fresh / migrate:refresh / migrate:reset / migrate:rollback / migrate:status / convert:migrations The following database-related artisan commands are NOT support at this time: -db:monitor / db:show / db:table / schema:dump +schema:dump ## Testing @@ -228,8 +233,9 @@ These methods don't work as ArangoDB requires you to declare the locking mechani ### Raw SQL Any raw SQL needs to be replaced by raw AQL. -### Separate read and write connections -Aranguent currently doesn't support the combination of a separate read and write connection +### Database replication: separate read and write connections +ArangoDB offers a cluster setup for high availability instead of replication and as such +doesn't support the combination of a separate read and write connection. ### Transactions [At the beginning of a transaction you must declare collections that are used in (write) statements.](transactions.md) diff --git a/docs/console-commands.md b/docs/console-commands.md new file mode 100644 index 0000000..0d5c781 --- /dev/null +++ b/docs/console-commands.md @@ -0,0 +1,64 @@ +# Console commands + +## db:wipe +db:wipe lets you clear all tables within current database. +In addition, you have the option to clear the following in ArangoDB: + +* --drop-analyzers: clear all custom analyzers, predefined system analyzers remain +* --drop-views: drop all views +* --drop-graphs: drop all named graphs +* --drop-all: drop all of the above: tables, analyzers, views and graphs + +## db:show +db:show gives you an overview of the current database and its tables. +In addition to the default Laravel options, you have the following: + +* --analyzers: show a list of available analyzers +* --views: show a list of available views +* --graphs: show a list of available named graphs +* --system: include system tables in the table list + +## db:table +db:table gives you an overview of the selected table. With ArangoDB specific information. + +The new --system option allows you to select a system table as well. + +## Migrations +_**Migrations for ArangoDB use a different Schema blueprint. Therefore, you either need to run the convert:migrations +command first, or convert them manually**_ + +The other migration commands work as expected with a few added features detailed below. + +### convert:migrations +`php artisan convert:migrations` converts all available migrations to their ArangoDB counterpart. +After this you use migrations as normal. + +If you are using a multi database setup with migrations for each, you'll want it convert them manually. +Which means importing the Blueprint and Facade from this package. + +Replace: +``` +Illuminate\Database\Schema\Blueprint; +Illuminate\Support\Facades\Schema; +``` +for: +``` +use LaravelFreelancerNL\Aranguent\Schema\Blueprint; +use LaravelFreelancerNL\Aranguent\Facades\Schema; +``` + +### migrate:fresh +`php artisan migrate:fresh` uses db:wipe under the hood and can use the same --drop-{feature} options. + +## make:migration +`php artisan make:migration` gives you the additional option to create an edge table. This presets +the proper collection type within the migration file. + +# make:model +`php artisan make:model` uses stubs with some preset docblock properties. In addition, you can +select the following stubs: + +``` + --edge-pivot The generated model uses a custom intermediate edge-collection model for ArangoDB + --edge-morph-pivot The generated model uses a custom polymorphic intermediate edge-collection model for ArangoDB + ``` \ No newline at end of file diff --git a/docs/key-options.md b/docs/key-options.md new file mode 100644 index 0000000..d46fc9d --- /dev/null +++ b/docs/key-options.md @@ -0,0 +1,55 @@ +# Key generator options +Tables in ArangoDB can be created using one of the available key generators. You can read up about them +[here](https://docs.arangodb.com/stable/concepts/data-structure/documents/#document-keys) +and [here](https://docs.arangodb.com/stable/develop/http-api/collections/#create-a-collection_body_keyOptions). + +The following assumes you have knowledge about ArangoDB keys as can be obtained through the above links. + +## Column defined key generators +Laravel has several column methods which can be used to set a primary key. If the given field is equal to: +'id', '_key' or '_id', the key generator will be set according to the mapping below. + +If these column methods are not found, or not called on these fields, the configured default generator is used. +You can also ignore the column methods by setting the config value +'arangodb.schema.key_handling.prioritize_configured_key_type' to true. + +By default, we map the key methods to the following ArangoDB key generators: + +| Laravel column method | ArangoDB key generator | +|:----------------------|:-----------------------| +| autoIncrement() | traditional | +| id() | traditional | +| increments('id') | traditional | +| smallIncrements | traditional | +| bigIncrements | traditional | +| mediumIncrements | traditional | +| uuid(id) | uuid | +| ulid(id) | _n/a_ | + +## Traditional vs autoincrement key generators +Even though ArangoDB has an autoincrement key generator we don't use it by default as it is not cluster safe. +The traditional key generator is similar to autoincrement: it is cluster safe although there may be gaps between +the _key increases. + +If you want the column methods to set the generator to autoincrement you can override the default behaviour by setting +the config value 'arangodb.schema.key_handling.use_traditional_over_autoincrement' to false. +In which case any given offset in the 'from' method is also used. + +## ulid +There is no ulid key generator in ArangoDB. The 'padded' generator may be used if you want +a lexigraphical sort order. You can do so by setting it in the config as the default key, and using configured keys only. +Or by setting it within the migration in the table options. + +## Table option key generators +You can set the key options for the table in the migration. This overrides both the default key options and the one defined by column methods. + +``` + Schema::create('taggables', function (Blueprint $collection) { + // + }, [ + 'keyOptions' => [ + 'type' => 'padded', + ], + ]); + ``` + diff --git a/docs/migrations.md b/docs/migrations.md index 329f1a2..a5b36e2 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -21,14 +21,15 @@ See the [ArangoDB Documentation for all options](https://docs.arangodb.com/3.3/H Within the collection blueprint you can create indexes. This following indexes are supported: -Type | Purpose | Blueprint Method ----------- |-----------------------------| ---------------- -Persistent | Ranged matching | `$table->index($columns = null, $name = null, $algorithm = null, $indexOptions = [])` -Primary * | Unique ranged matching | `$table->primary($columns = null, $name = null, $indexOptions = [])` -Unique | Unique ranged matching | `$table->unique($attributes, $indexOptions = [])` -Geo | Location matching | `$table->spatialIndex($columns, $name = null, $indexOptions = [])` -TTL | Auto-expiring documents | `$table->ttlIndex($columns, $expireAfter, $name = null, $indexOptions = [])` -Inverted | Fast full text searching | `$table->invertedIndex($columns = null, $name = null, $indexOptions = [])` +Type | Purpose | Blueprint Method +---------- |--------------------------| ---------------- +Persistent | Ranged matching | `$table->index($columns = null, $name = null, $algorithm = null, $indexOptions = [])` +Primary * | Unique ranged matching | `$table->primary($columns = null, $name = null, $indexOptions = [])` +Unique | Unique ranged matching | `$table->unique($attributes, $indexOptions = [])` +Geo | Location matching | `$table->spatialIndex($columns, $name = null, $indexOptions = [])` +TTL | Auto-expiring documents | `$table->ttlIndex($columns, $expireAfter, $name = null, $indexOptions = [])` +Inverted | Fast full text searching | `$table->invertedIndex($columns = null, $name = null, $indexOptions = [])` +multiDimensional | 2D+ numeric search | `$table->multiDimensionalIndex($columns = null, $name = null, $indexOptions = [], $type = 'mdi')` * the primary method is supported for composite keys. ArangoDB already sets a primary index on the _key property. @@ -87,17 +88,63 @@ Schema::dropView($viewName); ## Analyzers (ArangoSearch) You can create, edit or delete an ArangoDB Analyzer. -### New Analyzer +### New analyzer ```php -Schema::createAnalyzer($name, $config); +Schema::createAnalyzer($name, $type, $properties, $features); ``` -### Replace Analyzer +### Replace analyzer ```php -Schema::replaceAnalyzer($name, $config); +Schema::replaceAnalyzer($name, $type, $properties, $features); ``` -### Delete Analyzer +### Delete analyzer ```php Schema::dropAnalyzer($name); -``` \ No newline at end of file +``` + +### Delete all analyzers +```php +Schema::dropAnalyzers($name); +``` + +## Named Graphs +Named graphs are predefined managed graphs which feature integrity checks +compared to anonymous graphs. + +You can perform basic CRUD operations through the schema builder to handle named graphs. + +### Create a new graph +```php +Schema::createGraph($name, $properties, $waitForSync); +``` + +### Check for graph existence +```php +Schema::hasGraph($name); +``` + +### Get data of existing graph +```php +Schema::getGraph($name); +``` + +### Get all graphs +```php +Schema::getGraphs(); +``` + +### Delete a graph +```php +Schema::dropGraph($name); +``` + +### Delete a graph if it exists +```php +Schema::dropGraphIfExists($name); +``` + +### Delete all graphs +```php +Schema::dropGraphs(); +``` \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon index 8adfaa3..0e502c6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,11 +2,15 @@ includes: - vendor/larastan/larastan/extension.neon parameters: - level: 8 - checkGenericClassInNonGenericObjectType: false - universalObjectCratesClasses: - - 'Illuminate\Support\Fluent' - paths: - - src - excludePaths: - - src/Schema \ No newline at end of file + level: 8 + ignoreErrors: + - identifier: missingType.generics + - identifier: trait.unused + universalObjectCratesClasses: + - 'Illuminate\Support\Fluent' + paths: + - src + excludePaths: + - src/Schema + - src/Facades/Schema.php + treatPhpDocTypesAsCertain: false \ No newline at end of file diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 8adfaa3..0e502c6 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -2,11 +2,15 @@ includes: - vendor/larastan/larastan/extension.neon parameters: - level: 8 - checkGenericClassInNonGenericObjectType: false - universalObjectCratesClasses: - - 'Illuminate\Support\Fluent' - paths: - - src - excludePaths: - - src/Schema \ No newline at end of file + level: 8 + ignoreErrors: + - identifier: missingType.generics + - identifier: trait.unused + universalObjectCratesClasses: + - 'Illuminate\Support\Fluent' + paths: + - src + excludePaths: + - src/Schema + - src/Facades/Schema.php + treatPhpDocTypesAsCertain: false \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml index 146617a..105e788 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -29,7 +29,8 @@ - + + diff --git a/readme.md b/readme.md index ddd636e..2e9ba45 100644 --- a/readme.md +++ b/readme.md @@ -18,15 +18,27 @@ The goal is to create a drop-in ArangoDB replacement for Laravel's database, mig **This package is in development; use at your own peril.** ## Installation -You may use composer to install Aranguent: +This driver is currently in the v1 beta stage. +To install it make sure that the minimum stability is +set to beta or lower, and that prefer-stable is set to false in composer.json: + +``` + "minimum-stability": "beta", + "prefer-stable": false, +``` + +You may then use composer to install Aranguent: ``` composer require laravel-freelancer-nl/aranguent ``` + + ### Version compatibility -| Laravel | ArangoDB | PHP | Aranguent | -|:--------------|:---------|:-----|:----------| -| ^8.0 and ^9.0 | ^3.7 | ^8.0 | ^0.13 | -| ^11.0 | ^3.11 | ^8.2 | ^1.0.0 | +| Laravel | ArangoDB | PHP | Aranguent | +|:--------------|:---------|:-----|:-------------------------| +| ^8.0 and ^9.0 | ^3.7 | ^8.0 | ^0.13 | +| ^11.0 | ^3.11 | ^8.2 | ^1.0.0 - 1.0.0-beta.11 | +| ^12.0 | ^3.11 | ^8.2 | ^v1.0.0-beta.12 | ## Documentation 1) [Connect to ArangoDB](docs/connect-to-arangodb.md): set up a connection diff --git a/src/AranguentServiceProvider.php b/src/AranguentServiceProvider.php index a4121de..f450b9f 100644 --- a/src/AranguentServiceProvider.php +++ b/src/AranguentServiceProvider.php @@ -6,7 +6,9 @@ use Illuminate\Support\ServiceProvider; use LaravelFreelancerNL\Aranguent\Eloquent\Model; +use LaravelFreelancerNL\Aranguent\Eloquent\ModelInspector; use LaravelFreelancerNL\Aranguent\Schema\Grammar as SchemaGrammar; +use Illuminate\Database\Eloquent\ModelInspector as IlluminateModelInspector; class AranguentServiceProvider extends ServiceProvider { @@ -26,10 +28,12 @@ class AranguentServiceProvider extends ServiceProvider */ public function boot() { + /** @phpstan-ignore offsetAccess.nonOffsetAccessible */ if (isset($this->app['db'])) { Model::setConnectionResolver($this->app['db']); } + /** @phpstan-ignore offsetAccess.nonOffsetAccessible */ if (isset($this->app['events'])) { Model::setEventDispatcher($this->app['events']); } @@ -56,6 +60,10 @@ public function register() return $app['migrator']; }); + $this->app->extend(IlluminateModelInspector::class, function () { + return new ModelInspector($this->app); + }); + $this->app->resolving( 'db', function ($db) { @@ -64,7 +72,7 @@ function ($db) { function ($config, $name) { $config['name'] = $name; $connection = new Connection($config); - $connection->setSchemaGrammar(new SchemaGrammar()); + $connection->setSchemaGrammar(new SchemaGrammar($connection)); return $connection; }, diff --git a/src/Concerns/RunsQueries.php b/src/Concerns/RunsQueries.php index 383a6f7..28cae0e 100644 --- a/src/Concerns/RunsQueries.php +++ b/src/Concerns/RunsQueries.php @@ -11,9 +11,21 @@ use LaravelFreelancerNL\Aranguent\Exceptions\QueryException; use LaravelFreelancerNL\FluentAQL\QueryBuilder as FluentAqlBuilder; use stdClass; +use LaravelFreelancerNL\Aranguent\Exceptions\UniqueConstraintViolationException; trait RunsQueries { + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return boolval(preg_match('/409 - AQL: unique constraint violated/i', $exception->getMessage())); + } + /** * Run a select statement against the database and returns a generator. * @@ -21,7 +33,7 @@ trait RunsQueries * @param array $bindings * @param bool $useReadPdo * @return \Generator - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ public function cursor($query, $bindings = [], $useReadPdo = true) { @@ -183,7 +195,7 @@ protected function handleQueryBuilder($query, array $bindings): array /** * Run a select statement against the database. * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @param string|FluentAqlBuilder $query * @param array $bindings @@ -198,7 +210,7 @@ public function select($query, $bindings = [], $useReadPdo = true) /** * Run an AQL query against the database and return the results. * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @param string|FluentAqlBuilder $query * @param array $bindings @@ -283,7 +295,7 @@ protected function run($query, $bindings, Closure $callback) $this->logQuery( $query, $bindings, - $this->getElapsedTime((int) $start), + $this->getElapsedTime($start), ); return $result; @@ -310,6 +322,15 @@ protected function runQueryCallback($query, $bindings, Closure $callback) // message to include the bindings with SQL, which will make this exception a // lot more helpful to the developer instead of just the database's errors. + if ($this->isUniqueConstraintError($e)) { + throw new UniqueConstraintViolationException( + (string) $this->getName(), + $query, + $this->prepareBindings($bindings), + $e, + ); + } + throw new QueryException( (string) $this->getName(), $query, diff --git a/src/Connection.php b/src/Connection.php index bf2584b..3ca2992 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -27,7 +27,10 @@ class Connection extends IlluminateConnection use ManagesTransactions; use RunsQueries; - protected ?ArangoClient $arangoClient = null; + /** + * @var ArangoClient|null + */ + protected $arangoClient; /** * The ArangoDB driver name. @@ -90,7 +93,7 @@ protected function getDefaultPostProcessor(): Processor */ protected function getDefaultQueryGrammar() { - ($grammar = new QueryGrammar())->setConnection($this); + $grammar = new QueryGrammar($this); return $grammar; } @@ -198,11 +201,11 @@ protected function escapeBinary($value) * @param bool $binary * @return string * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ public function escape($value, $binary = false) { - return match(gettype($value)) { + return match (gettype($value)) { 'array' => $this->escapeArray($value), 'boolean' => $this->escapeBool($value), 'double' => (string) $value, @@ -218,7 +221,7 @@ public function escape($value, $binary = false) * @param string $value * @return string * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ protected function escapeString($value, bool $binary = false) { @@ -249,7 +252,7 @@ protected function escapeString($value, bool $binary = false) */ protected function escapeArray(array $array): string { - foreach($array as $key => $value) { + foreach ($array as $key => $value) { $array[$key] = $this->escape($value); } @@ -282,4 +285,34 @@ protected function getElapsedTime($start) { return round((microtime(true) - $start) * 1000, 2); } + + /** + * Get the number of open connections for the database. + * + * @return int|null + */ + public function threadCount() + { + if (!$this->arangoClient) { + return null; + } + + return $this->arangoClient->monitor()->getCurrentConnections(); + } + + /** + * Get the server version for the connection. + * + * @return string + */ + public function getServerVersion(): string + { + if (!$this->arangoClient) { + return ''; + } + + $rawVersion = $this->arangoClient->admin()->getVersion(); + + return $rawVersion->version; + } } diff --git a/src/Console/DbCommand.php b/src/Console/DbCommand.php index 465fdf4..b0dfda2 100644 --- a/src/Console/DbCommand.php +++ b/src/Console/DbCommand.php @@ -14,7 +14,7 @@ class DbCommand extends IlluminateDbCommand * * @return int * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ public function handle() { diff --git a/src/Console/Migrations/FreshCommand.php b/src/Console/Migrations/FreshCommand.php new file mode 100644 index 0000000..88d096a --- /dev/null +++ b/src/Console/Migrations/FreshCommand.php @@ -0,0 +1,94 @@ +isProhibited() || + ! $this->confirmToProceed()) { + return Command::FAILURE; + } + + $database = $this->input->getOption('database'); + + $this->migrator->usingConnection($database, function () use ($database) { + if ($this->migrator->repositoryExists()) { + $this->newLine(); + + $this->components->task('Dropping all tables', fn() => $this->callSilent('db:wipe', array_filter([ + '--database' => $database, + '--drop-all' => $this->option('drop-all'), + '--drop-analyzers' => $this->option('drop-analyzers'), + '--drop-graphs' => $this->option('drop-graphs'), + '--drop-views' => $this->option('drop-views'), + '--drop-types' => $this->option('drop-types'), + '--force' => true, + ])) == 0); + } + }); + + $this->newLine(); + + $this->call('migrate', array_filter([ + '--database' => $database, + '--path' => $this->input->getOption('path'), + '--realpath' => $this->input->getOption('realpath'), + '--schema-path' => $this->input->getOption('schema-path'), + '--force' => true, + '--step' => $this->option('step'), + ])); + + if ($this->laravel->bound(Dispatcher::class)) { + /** @phpstan-ignore offsetAccess.nonOffsetAccessible */ + $this->laravel[Dispatcher::class]->dispatch( + new DatabaseRefreshed($database, $this->needsSeeding()), + ); + } + + if ($this->needsSeeding()) { + $this->runSeeder($database); + } + + return 0; + } + + /** + * Get the console command options. + * + * @return array> + */ + protected function getOptions() + { + return [ + ['database', null, InputOption::VALUE_OPTIONAL, 'The database connection to use'], + ['drop-all', null, InputOption::VALUE_NONE, 'Drop all tables, views, custom analyzers and named graphs (ArangoDB only)'], + ['drop-analyzers', null, InputOption::VALUE_NONE, 'Drop all tables and custom analyzers (ArangoDB only)'], + ['drop-graphs', null, InputOption::VALUE_NONE, 'Drop all tables and named graphs (ArangoDB only)'], + ['drop-views', null, InputOption::VALUE_NONE, 'Drop all tables and views (ArangoDB only)'], + ['drop-types', null, InputOption::VALUE_NONE, 'Drop all tables and types (Postgres only)'], + ['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'], + ['path', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'The path(s) to the migrations files to be executed'], + ['realpath', null, InputOption::VALUE_NONE, 'Indicate any provided migration file paths are pre-resolved absolute paths'], + ['schema-path', null, InputOption::VALUE_OPTIONAL, 'The path to a schema dump file'], + ['seed', null, InputOption::VALUE_NONE, 'Indicates if the seed task should be re-run'], + ['seeder', null, InputOption::VALUE_OPTIONAL, 'The class name of the root seeder'], + ['step', null, InputOption::VALUE_NONE, 'Force the migrations to be run so they can be rolled back individually'], + ]; + } +} diff --git a/src/Console/Migrations/MigrateMakeCommand.php b/src/Console/Migrations/MigrateMakeCommand.php index 3fae1ee..d782055 100644 --- a/src/Console/Migrations/MigrateMakeCommand.php +++ b/src/Console/Migrations/MigrateMakeCommand.php @@ -111,7 +111,7 @@ public function handle() * * @throws Exception * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ protected function writeMigration($name, $table, $create, $edge = false) { diff --git a/src/Console/ShowCommand.php b/src/Console/ShowCommand.php new file mode 100644 index 0000000..5da75d1 --- /dev/null +++ b/src/Console/ShowCommand.php @@ -0,0 +1,343 @@ +connection($database = $this->input->getOption('database')); + + assert($connection instanceof Connection); + + if ($connection->getDriverName() !== 'arangodb') { + return parent::handle($connections); + } + + $schema = $connection->getSchemaBuilder(); + + $fullVersion = $this->getFullVersion($connection); + + $data = [ + 'platform' => [ + 'config' => $this->getConfigFromDatabase($database), + 'server' => $fullVersion->server ?? 'arango', + 'license' => $fullVersion->license ?? 'unknown', + 'name' => $connection->getDriverTitle(), + 'connection' => $connection->getName(), + 'version' => $fullVersion->version ?? 'unknown', + 'isSystemDatabase' => $this->getDatabaseInfo($connection), + 'open_connections' => $connection->threadCount(), + ], + 'tables' => $this->tables($connection, $schema), + ]; + + $data['views'] = $this->views($connection, $schema); + + $data['analyzers'] = $this->analyzers($schema); + + $data['graphs'] = $this->graphs($schema); + + $this->display($data, $connection); + + return 0; + } + + /** + * Render the database information. + * + * @param array $data + * @param Connection|null $connection + * @return void + */ + protected function display(array $data, ?Connection $connection = null) + { + $this->option('json') ? $this->displayJson($data) : $this->displayForCli($data, $connection); + } + + + /** + * Get information regarding the tables within the database. + * + * @param \Illuminate\Database\ConnectionInterface $connection + * @param IlluminateSchemaBuilder $schema + * @return \Illuminate\Support\Collection + */ + protected function tables(ConnectionInterface $connection, $schema) + { + assert($connection instanceof Connection); + + if ($connection->getDriverName() !== 'arangodb') { + return parent::tables($connection, $schema); + } + + assert($schema instanceof SchemaBuilder); + + // Get all tables + $tables = collect( + ($this->input->getOption('system')) ? $schema->getAllTables() : $schema->getTables(), + )->sortBy('name'); + + // Get per table statistics + $tableStats = []; + foreach ($tables as $table) { + $tableStats[] = $schema->getTable($table['name']); + } + + return collect($tableStats)->map(fn($table) => [ + 'table' => $table['name'], + 'size' => $table['figures']->documentsSize, + 'rows' => $this->option('counts') + ? $table['count'] + : null, + ]); + } + + /** + * Get information regarding the views within the database. + * + * @param \Illuminate\Database\ConnectionInterface $connection + * @param \Illuminate\Database\Schema\Builder $schema + * @return \Illuminate\Support\Collection + */ + protected function views(ConnectionInterface $connection, IlluminateSchemaBuilder $schema) + { + assert($connection instanceof Connection); + + if ($connection->getDriverName() !== 'arangodb') { + return parent::views($connection, $schema); + } + + return collect($schema->getViews()) + ->map(fn($view) => [ + 'name' => $view['name'], + 'type' => $view['type'], + ]); + } + + /** + * Get information regarding the analyzers within the database. + * + * @param SchemaBuilder $schema + * @return \Illuminate\Support\Collection + */ + protected function analyzers(SchemaBuilder $schema) + { + return collect($schema->getAnalyzers()) + ->map(fn($analyzer) => [ + 'name' => $analyzer['name'], + 'type' => $analyzer['type'], + ]); + } + + /** + * Get information regarding the named graphs within the database. + * + * @param SchemaBuilder $schema + * @return \Illuminate\Support\Collection + */ + protected function graphs(SchemaBuilder $schema) + { + return collect($schema->getGraphs()) + ->map(fn($graph) => [ + 'name' => $graph['name'], + 'edgeDefinitions' => count($graph['edgeDefinitions']), + ]); + } + + protected function getFullVersion(Connection $connection): object + { + $client = $connection->getArangoClient(); + + assert($client !== null); + + return $client->admin()->getVersion(); + } + + /** + * @throws ArangoException + */ + protected function getDatabaseInfo(Connection $connection): bool + { + $client = $connection->getArangoClient(); + + assert($client !== null); + + $info = $client->schema()->getCurrentDatabase(); + + return $info->isSystem; + } + + /** + * @param mixed $views + * @return void + */ + public function displayViews(mixed $views): void + { + if (! $this->input->getOption('views') || $views->isEmpty()) { + return; + } + + $this->components->twoColumnDetail( + 'View', + 'Type', + ); + + $views->each(fn($view) => $this->components->twoColumnDetail( + $view['name'], + $view['type'], + )); + + $this->newLine(); + } + + /** + * @param mixed $analyzers + * @return void + */ + public function displayAnalyzers(mixed $analyzers): void + { + if (! $this->input->getOption('analyzers') || $analyzers->isEmpty()) { + return; + } + + $this->components->twoColumnDetail( + 'Analyzers', + 'Type', + ); + + $analyzers->each(fn($analyzer) => $this->components->twoColumnDetail( + $analyzer['name'], + $analyzer['type'], + )); + + $this->newLine(); + } + /** + * @param mixed $graphs + * @return void + */ + public function displayGraphs(mixed $graphs): void + { + if (! $this->input->getOption('graphs') || $graphs->isEmpty()) { + return; + } + + $this->components->twoColumnDetail( + 'Graphs', + 'Edge Definitions', + ); + + $graphs->each(fn($graph) => $this->components->twoColumnDetail( + $graph['name'], + $graph['edgeDefinitions'], + )); + + $this->newLine(); + } + + /** + * Render the database information formatted for the CLI. + * + * @param array $data + * @param Connection|null $connection + * @return void + */ + protected function displayForCli(array $data, ?Connection $connection = null) + { + if ($connection && $connection->getDriverName() !== 'arangodb') { + parent::displayForCli($data); + return; + } + + $platform = $data['platform']; + $tables = $data['tables']; + $analyzers = $data['analyzers'] ?? null; + $views = $data['views'] ?? null; + $graphs = $data['graphs'] ?? null; + + $this->newLine(); + + $this->components->twoColumnDetail('ArangoDB (' . ucfirst($platform['license']) . ' Edition)', '' . $platform['version'] . ''); + $this->components->twoColumnDetail('Connection', $platform['connection']); + $this->components->twoColumnDetail('Database', Arr::get($platform['config'], 'database')); + $this->components->twoColumnDetail('Host', Arr::get($platform['config'], 'host')); + $this->components->twoColumnDetail('Port', Arr::get($platform['config'], 'port')); + $this->components->twoColumnDetail('Username', Arr::get($platform['config'], 'username')); + $this->components->twoColumnDetail('URL', Arr::get($platform['config'], 'url') ?? Arr::get($platform['config'], 'endpoint')); + $this->components->twoColumnDetail('Open Connections', $platform['open_connections']); + $this->components->twoColumnDetail('Analyzers', $analyzers->count()); + $this->components->twoColumnDetail('Views', $views->count()); + $this->components->twoColumnDetail('Named Graphs', $graphs->count()); + $this->components->twoColumnDetail('Tables', $tables->count()); + + $tableSizeSum = $tables->sum('size'); + if ($tableSizeSum) { + $this->components->twoColumnDetail('Total Size Estimate', Number::fileSize($tableSizeSum, 2)); + } + + $this->newLine(); + + if ($tables->isNotEmpty()) { + $this->components->twoColumnDetail( + 'Table', + 'Size Estimate' . ($this->option('counts') ? ' / Rows' : ''), + ); + + $tables->each(function ($table) { + $tableSize = is_null($table['size']) ? null : Number::fileSize($table['size'], 2); + + $this->components->twoColumnDetail( + $table["table"], + ($tableSize ?? '—') . ($this->option('counts') ? ' / ' . Number::format($table['rows']) . '' : ''), + ); + }); + + $this->newLine(); + } + + $this->displayViews($views); + + $this->displayAnalyzers($analyzers); + + $this->displayGraphs($graphs); + } +} diff --git a/src/Console/ShowModelCommand.php b/src/Console/ShowModelCommand.php new file mode 100644 index 0000000..35b6d10 --- /dev/null +++ b/src/Console/ShowModelCommand.php @@ -0,0 +1,175 @@ +inspect( + $this->argument('model'), + $this->option('database'), + ); + } catch (BindingResolutionException $e) { + $this->components->error($e->getMessage()); + + return 1; + } + + $this->display( + $info['class'], + $info['database'], + $info['table'], + $info['policy'], + $info['attributes'], + $info['relations'], + $info['events'], + $info['observers'], + ); + + return 0; + } + + /** + * Render the model information. + * + * @param class-string<\Illuminate\Database\Eloquent\Model> $class + * @param string $database + * @param string $table + * @param class-string|null $policy + * @param Collection $attributes + * @param Collection $relations + * @param Collection $events + * @param Collection $observers + * @return void + */ + protected function display($class, $database, $table, $policy, $attributes, $relations, $events, $observers) + { + $this->option('json') + ? $this->displayJson($class, $database, $table, $policy, $attributes, $relations, $events, $observers) + : $this->displayCli($class, $database, $table, $policy, $attributes, $relations, $events, $observers); + } + + /** + * Render the model information for the CLI. + * + * @param class-string<\Illuminate\Database\Eloquent\Model> $class + * @param string $database + * @param string $table + * @param class-string|null $policy + * @param Collection $attributes + * @param Collection $relations + * @param Collection $events + * @param Collection $observers + * @return void + */ + protected function displayCli($class, $database, $table, $policy, $attributes, $relations, $events, $observers) + { + $this->newLine(); + + $this->components->twoColumnDetail('' . $class . ''); + $this->components->twoColumnDetail('Database', $database); + $this->components->twoColumnDetail('Table', $table); + + if ($policy) { + $this->components->twoColumnDetail('Policy', $policy); + } + + $this->newLine(); + + $this->components->twoColumnDetail( + 'Attributes', + 'type / cast', + ); + + foreach ($attributes as $attribute) { + $first = trim(sprintf( + '%s %s', + $attribute['name'], + collect(['computed', 'increments', 'unique', 'nullable', 'fillable', 'hidden', 'appended']) + ->filter(fn($property) => (isset($attribute[$property])) ? $attribute[$property] : false) + ->map(fn($property) => sprintf('%s', $property)) + ->implode(', '), + )); + + $second = collect([ + (is_array($attribute['type'])) ? implode(', ', $attribute['type']) : $attribute['type'], + $attribute['cast'] ? '' . $attribute['cast'] . '' : null, + ])->filter()->implode(' / '); + + $this->components->twoColumnDetail($first, $second); + } + + $this->newLine(); + + $this->components->twoColumnDetail('Relations'); + + foreach ($relations as $relation) { + $this->components->twoColumnDetail( + sprintf('%s %s', $relation['name'], $relation['type']), + $relation['related'], + ); + } + + $this->newLine(); + + $this->displayCliEvents($events, $observers); + + $this->newLine(); + } + + /** + * @param Collection $events + * @return void + */ + public function displayCliEvents(Collection $events, Collection $observers): void + { + $this->components->twoColumnDetail('Events'); + + if ($events->count()) { + foreach ($events as $event) { + $this->components->twoColumnDetail( + sprintf('%s', $event['event']), + sprintf('%s', $event['class']), + ); + } + } + + $this->newLine(); + + $this->components->twoColumnDetail('Observers'); + + if ($observers->count()) { + foreach ($observers as $observer) { + $this->components->twoColumnDetail( + sprintf('%s', $observer['event']), + implode(', ', $observer['observer']), + ); + } + } + + } + +} diff --git a/src/Console/TableCommand.php b/src/Console/TableCommand.php new file mode 100644 index 0000000..ff620ff --- /dev/null +++ b/src/Console/TableCommand.php @@ -0,0 +1,221 @@ +connection($this->input->getOption('database')); + + if (! $connection instanceof Connection) { + return parent::handle($connections); + } + + $schema = $connection->getSchemaBuilder(); + + $tables = collect( + ($this->input->getOption('system')) + ? $schema->getAllTables() + : $schema->getTables(), + ) + ->keyBy(fn($table) => (string) $table['name']) + ->all(); + + $tableName = (string) $this->argument('table') ?: select( + 'Which table would you like to inspect?', + array_keys($tables), + ); + + $table = $schema->getTable((string) $tableName); + + if (! $table) { + $this->components->warn("Table [{$tableName}] doesn't exist."); + + return 1; + } + + [$columns, $indexes] = $connection->withoutTablePrefix(function ($connection) use ($table) { + $schema = $connection->getSchemaBuilder(); + $tableName = $table['name']; + + return [ + $this->columns($schema, $tableName), + $this->indexes($schema, $tableName), + ]; + }); + + + + $data = [ + 'table' => $table, + 'columns' => $columns, + 'indexes' => $indexes, + ]; + + $this->display($data); + + return 0; + } + + /** + * Get the information regarding the table's columns. + * + * @param \Illuminate\Database\Schema\Builder $schema + * @param string $table + * @return \Illuminate\Support\Collection + */ + protected function columns(Builder $schema, string $table) + { + return collect($schema->getColumns($table)); + } + + /** + * Get the information regarding the table's indexes. + * + * @param \Illuminate\Database\Schema\Builder $schema + * @param string $table + * @return \Illuminate\Support\Collection + */ + protected function indexes(Builder $schema, string $table) + { + return collect($schema->getIndexes($table))->map(fn($index) => [ + 'name' => (string) $index['name'], + 'columns' => collect((array) $index['fields']), + 'attributes' => $this->getAttributesForIndex((array) $index), + ]); + } + + /** + * Get the attributes for a table index. + * + * @param array $index + * @return \Illuminate\Support\Collection + */ + protected function getAttributesForIndex($index) + { + return collect( + array_filter([ + 'sparse' => $index['sparse'] ? 'sparse' : null, + 'unique' => $index['unique'] ? 'unique' : null, + 'type' => $index['type'], + ]), + )->filter(); + } + + /** + * Render the table information. + * + * @param mixed[] $data + * @return void + */ + protected function display(array $data) + { + $this->option('json') ? $this->displayJson($data) : $this->displayForCli($data); + } + + protected function displayLongStringValue(string $value): string + { + if (strlen($value) < 136) { + return $value; + } + return substr($value, 0, 133) . '...'; + } + + /** + * Render the table information formatted for the CLI. + * + * @param mixed[] $data + * @return void + */ + protected function displayForCli(array $data) + { + [$table, $columns, $indexes ] = [ + $data['table'], $data['columns'], $data['indexes'], + ]; + + $this->newLine(); + + $this->components->twoColumnDetail('Table', '' . $table['name'] . ''); + $this->components->twoColumnDetail('Type', ($table['type'] == 2) ? 'Vertex' : 'Edge'); + $this->components->twoColumnDetail('Status', $table['statusString']); + $this->components->twoColumnDetail('User Keys Allowed', ($table['keyOptions']->allowUserKeys) ? 'Yes' : 'No'); + $this->components->twoColumnDetail('Key Type', $table['keyOptions']->type); + $this->components->twoColumnDetail('Last Used Key', $table['keyOptions']->lastValue); + $this->components->twoColumnDetail('Wait For Sync', ($table['waitForSync']) ? 'Yes' : 'No'); + $this->components->twoColumnDetail('Columns', $table['count']); + $this->components->twoColumnDetail('Size Estimate', Number::fileSize($table['figures']->documentsSize, 2)); + + $this->newLine(); + + if ($columns->isNotEmpty()) { + $this->components->twoColumnDetail('Column', 'Type'); + + $columns->each(function ($column) { + $this->components->twoColumnDetail( + $column['name'], + implode(', ', $column['type']), + ); + }); + $this->components->info('ArangoDB is schemaless by default. Hence, the column & types are a representation of current data within the table.'); + } + + $computedValues = collect((array) $table['computedValues']); + if ($computedValues->isNotEmpty()) { + $this->components->twoColumnDetail('Computed Value', 'Expression'); + + $computedValues->each(function ($value) { + $this->components->twoColumnDetail( + $value->name, + $this->displayLongStringValue($value->expression), + ); + }); + + $this->newLine(); + } + + if ($indexes->isNotEmpty()) { + $this->components->twoColumnDetail('Index'); + + $indexes->each(function ($index) { + $this->components->twoColumnDetail( + $index['name'] . ' ' . $index['columns']->implode(', ') . '', + $index['attributes']->implode(', '), + ); + }); + + $this->newLine(); + } + } +} diff --git a/src/Console/WipeCommand.php b/src/Console/WipeCommand.php new file mode 100644 index 0000000..f616064 --- /dev/null +++ b/src/Console/WipeCommand.php @@ -0,0 +1,119 @@ +isProhibited() || + ! $this->confirmToProceed()) { + return Command::FAILURE; + } + + $database = $this->input->getOption('database'); + + $this->handleDrops($database); + + return 0; + } + + /** + * @param string $database + * @return void + */ + protected function handleDrops($database) + { + if ($this->option('drop-graphs') || $this->option('drop-all')) { + $this->dropAllGraphs($database); + + $this->components->info('Dropped all named graphs successfully.'); + } + + if ($this->option('drop-views') || $this->option('drop-all')) { + $this->dropAllViews($database); + + $this->components->info('Dropped all views successfully.'); + } + + $this->dropAllTables($database); + + $this->components->info('Dropped all tables successfully.'); + + if ($this->option('drop-types')) { + $this->dropAllTypes($database); + + $this->components->info('Dropped all types successfully.'); + } + + if ($this->option('drop-analyzers') || $this->option('drop-all')) { + $this->dropAllAnalyzers($database); + + $this->components->info('Dropped all analyzers successfully.'); + } + } + + /** + * Drop all of the database analyzers. + * + * @param null|string $database + * @return void + */ + protected function dropAllAnalyzers($database) + { + /** @phpstan-ignore offsetAccess.nonOffsetAccessible */ + $this->laravel['db']->connection($database) + ->getSchemaBuilder() + ->dropAllAnalyzers(); + } + + /** + * Drop all of the database analyzers. + * + * @param null|string $database + * @return void + */ + protected function dropAllGraphs($database) + { + /** @phpstan-ignore offsetAccess.nonOffsetAccessible */ + $this->laravel['db']->connection($database) + ->getSchemaBuilder() + ->dropAllGraphs(); + } + + /** + * Get the console command options. + * + * @return array> + */ + protected function getOptions() + { + return [ + ['database', null, InputOption::VALUE_OPTIONAL, 'The database connection to use'], + ['drop-all', null, InputOption::VALUE_NONE, 'Drop all tables, views, custom analyzers and named graphs (ArangoDB only)'], + ['drop-analyzers', null, InputOption::VALUE_NONE, 'Drop all tables and custom analyzers (ArangoDB only)'], + ['drop-graphs', null, InputOption::VALUE_NONE, 'Drop all tables and named graphs (ArangoDB only)'], + ['drop-views', null, InputOption::VALUE_NONE, 'Drop all tables and views (ArangoDB only)'], + ['drop-types', null, InputOption::VALUE_NONE, 'Drop all tables and types (Postgres only)'], + ['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'], + ]; + } +} diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 3e95c91..ad841bb 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Builder as IlluminateEloquentBuilder; use Illuminate\Support\Arr; use LaravelFreelancerNL\Aranguent\Eloquent\Concerns\QueriesAranguentRelationships; +use LaravelFreelancerNL\Aranguent\Exceptions\UniqueConstraintViolationException; use LaravelFreelancerNL\Aranguent\Query\Builder as QueryBuilder; class Builder extends IlluminateEloquentBuilder @@ -20,6 +21,22 @@ class Builder extends IlluminateEloquentBuilder */ protected $query; + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @param mixed[] $attributes + * @param mixed[] $values + * @return \Illuminate\Database\Eloquent\Model|static + */ + public function createOrFirst(array $attributes = [], array $values = []) + { + try { + return $this->withSavepointIfNeeded(fn() => $this->create(array_merge($attributes, $values))); + } catch (UniqueConstraintViolationException $e) { + return $this->useWritePdo()->where($attributes)->first() ?? throw $e; + } + } + /** * Insert a record in the database. * diff --git a/src/Eloquent/Casts/AsArrayObject.php b/src/Eloquent/Casts/AsArrayObject.php index a956d74..b57203c 100644 --- a/src/Eloquent/Casts/AsArrayObject.php +++ b/src/Eloquent/Casts/AsArrayObject.php @@ -17,11 +17,11 @@ class AsArrayObject extends IlluminateAsArrayObject * @param array $arguments * @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Database\Eloquent\Casts\ArrayObject, iterable> * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ public static function castUsing(array $arguments) { - return new class () implements CastsAttributes { + return new class implements CastsAttributes { public function get($model, $key, $value, $attributes) { if (! isset($attributes[$key])) { @@ -31,7 +31,7 @@ public function get($model, $key, $value, $attributes) $data = $attributes[$key]; if (is_object($data)) { - $data = (array) $data; + $data = mapObjectToArray($data); } return is_array($data) ? new ArrayObject($data, ArrayObject::ARRAY_AS_PROPS) : null; @@ -44,7 +44,7 @@ public function get($model, $key, $value, $attributes) * @param mixed[] $attributes * @return mixed[] * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ public function set($model, $key, $value, $attributes) { diff --git a/src/Eloquent/Casts/AsCollection.php b/src/Eloquent/Casts/AsCollection.php index e10056e..3507e08 100644 --- a/src/Eloquent/Casts/AsCollection.php +++ b/src/Eloquent/Casts/AsCollection.php @@ -11,8 +11,8 @@ use LaravelFreelancerNL\Aranguent\Eloquent\Model; /** - * @SuppressWarnings(PHPMD.UndefinedVariable) - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UndefinedVariable") + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ class AsCollection extends IlluminateAsCollection { @@ -37,7 +37,7 @@ public function __construct(protected array $arguments) {} * @param $attributes * @return Collection|mixed|void|null * - * @SuppressWarnings(PHPMD.UndefinedVariable) + * @SuppressWarnings("PHPMD.UndefinedVariable") */ public function get($model, $key, $value, $attributes) { @@ -68,7 +68,7 @@ public function get($model, $key, $value, $attributes) * @param mixed[] $attributes * @return mixed[] * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ public function set($model, $key, $value, $attributes) { diff --git a/src/Eloquent/Casts/AsEnumArrayObject.php b/src/Eloquent/Casts/AsEnumArrayObject.php index 54f4915..dc6df72 100644 --- a/src/Eloquent/Casts/AsEnumArrayObject.php +++ b/src/Eloquent/Casts/AsEnumArrayObject.php @@ -10,8 +10,8 @@ use LaravelFreelancerNL\Aranguent\Eloquent\Model; /** - * @SuppressWarnings(PHPMD.UndefinedVariable) - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UndefinedVariable") + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ class AsEnumArrayObject extends IlluminateAsEnumArrayObjectAlias { @@ -23,7 +23,7 @@ class AsEnumArrayObject extends IlluminateAsEnumArrayObjectAlias * @param array{class-string} $arguments * @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Database\Eloquent\Casts\ArrayObject, iterable> * - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings("PHPMD.ExcessiveMethodLength") */ public static function castUsing(array $arguments) { @@ -36,7 +36,7 @@ public static function castUsing(array $arguments) /** * @param array> $arguments * - * @SuppressWarnings(PHPMD.UndefinedVariable) + * @SuppressWarnings("PHPMD.UndefinedVariable") */ public function __construct(array $arguments) { @@ -50,7 +50,7 @@ public function __construct(array $arguments) * @param $attributes * @return ArrayObject|void * - * @SuppressWarnings(PHPMD.UndefinedVariable) + * @SuppressWarnings("PHPMD.UndefinedVariable") */ public function get($model, $key, $value, $attributes) { @@ -83,8 +83,8 @@ public function get($model, $key, $value, $attributes) * @param mixed[] $attributes * @return mixed[] * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - * @SuppressWarnings(PHPMD.UndefinedVariable) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") + * @SuppressWarnings("PHPMD.UndefinedVariable") */ public function set($model, $key, $value, $attributes) { @@ -136,7 +136,7 @@ protected function getStorableEnumValue($enum) * @param class-string $class * @return string * - * @SuppressWarnings(PHPMD.ShortMethodName) + * @SuppressWarnings("PHPMD.ShortMethodName") */ public static function of($class) { diff --git a/src/Eloquent/Casts/AsEnumCollection.php b/src/Eloquent/Casts/AsEnumCollection.php index 334323a..37bbeef 100644 --- a/src/Eloquent/Casts/AsEnumCollection.php +++ b/src/Eloquent/Casts/AsEnumCollection.php @@ -11,9 +11,9 @@ use LaravelFreelancerNL\Aranguent\Eloquent\Model; /** - * @SuppressWarnings(PHPMD.UndefinedVariable) - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - * @SuppressWarnings(PHPMD.ShortMethodName) + * @SuppressWarnings("PHPMD.UndefinedVariable") + * @SuppressWarnings("PHPMD.UnusedFormalParameter") + * @SuppressWarnings("PHPMD.ShortMethodName") */ class AsEnumCollection extends IlluminateAsEnumCollection { diff --git a/src/Eloquent/Concerns/HasAranguentRelationships.php b/src/Eloquent/Concerns/HasAranguentRelationships.php index 3289b8a..b956e08 100644 --- a/src/Eloquent/Concerns/HasAranguentRelationships.php +++ b/src/Eloquent/Concerns/HasAranguentRelationships.php @@ -163,8 +163,8 @@ protected function newMorphTo(Builder $query, Model $parent, $foreignKey, $owner * * Laravel API PHPMD exclusions * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) - * @SuppressWarnings(PHPMD.ExcessiveParameterList) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") + * @SuppressWarnings("PHPMD.ExcessiveParameterList") * * @param string $name * @param string $table diff --git a/src/Eloquent/Concerns/HasAttributes.php b/src/Eloquent/Concerns/HasAttributes.php index e073351..7ed02e4 100644 --- a/src/Eloquent/Concerns/HasAttributes.php +++ b/src/Eloquent/Concerns/HasAttributes.php @@ -24,7 +24,7 @@ protected function isJsonCastable($key) * @param bool $asObject * @return mixed * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ public function fromJson($value, $asObject = false) { @@ -33,6 +33,7 @@ public function fromJson($value, $asObject = false) return (object) $value; } - return (array) $value; + // Recursively cast objects within the value to arrays + return mapObjectToArray($value); } } diff --git a/src/Eloquent/Concerns/IsAranguentModel.php b/src/Eloquent/Concerns/IsAranguentModel.php index 37a63ee..15755ba 100644 --- a/src/Eloquent/Concerns/IsAranguentModel.php +++ b/src/Eloquent/Concerns/IsAranguentModel.php @@ -34,7 +34,9 @@ protected function insertAndSetId(IlluminateEloquentBuilder $query, $attributes) $matches = []; preg_match('/\/(.*)$/', $id, $matches); - $this->setAttribute('id', $matches[1]); + // We know the exact string format for $matches when the attribute is _id + /** @var array{0: string, 1: string} $matches */ + $this->setAttribute('id', $matches[1]); // @phpstan-ignore arrayUnpacking.stringOffset } if ($keyName === 'id' || $keyName === '_key') { $this->updateIdWithKey($id); diff --git a/src/Eloquent/Concerns/QueriesAranguentRelationships.php b/src/Eloquent/Concerns/QueriesAranguentRelationships.php index 7f50367..43ed9b0 100644 --- a/src/Eloquent/Concerns/QueriesAranguentRelationships.php +++ b/src/Eloquent/Concerns/QueriesAranguentRelationships.php @@ -125,7 +125,7 @@ public function mergeConstraintsFrom(Builder $from) * @param string $function * @return $this * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings("PHPMD.CyclomaticComplexity") */ public function withAggregate($relations, $column, $function = null) { @@ -169,7 +169,7 @@ public function withAggregate($relations, $column, $function = null) $query = $relation->getRelationExistenceQuery( $relation->getRelated()->newQuery(), $this, - new Expression($expression), + [$expression], )->setBindings([], 'select'); $query->callScope($constraints); @@ -179,7 +179,8 @@ public function withAggregate($relations, $column, $function = null) // If the query contains certain elements like orderings / more than one column selected // then we will remove those elements from the query so that it will execute properly // when given to the database. Otherwise, we may receive SQL errors or poor syntax. - unset($query->orders); + $query->orders = null; + $query->setBindings([], 'order'); if (is_array($query->columns) && count($query->columns) > 1) { diff --git a/src/Eloquent/ModelInspector.php b/src/Eloquent/ModelInspector.php new file mode 100644 index 0000000..a0627b2 --- /dev/null +++ b/src/Eloquent/ModelInspector.php @@ -0,0 +1,187 @@ + + */ + protected $relationMethods = [ + 'hasMany', + 'hasManyThrough', + 'hasOneThrough', + 'belongsToMany', + 'hasOne', + 'belongsTo', + 'morphOne', + 'morphTo', + 'morphMany', + 'morphToMany', + 'morphedByMany', + ]; + + /** + * Extract model details for the given model. + * + * @param class-string<\Illuminate\Database\Eloquent\Model>|string $model + * @param string|null $connection + * @return array{"class": class-string<\Illuminate\Database\Eloquent\Model>, database: string, table: string, policy: class-string|null, attributes: Collection, relations: Collection, events: Collection, observers: Collection, collection: class-string<\Illuminate\Database\Eloquent\Collection<\Illuminate\Database\Eloquent\Model>>, builder: class-string<\Illuminate\Database\Eloquent\Builder<\Illuminate\Database\Eloquent\Model>>} + * + * @throws BindingResolutionException + */ + public function inspect($model, $connection = null) + { + $class = $this->qualifyModel($model); + + /** @var \Illuminate\Database\Eloquent\Model $model */ + $model = $this->app->make($class); + + if ($connection !== null) { + $model->setConnection($connection); + } + + + /* @phpstan-ignore-next-line */ + return [ + 'class' => get_class($model), + 'database' => $model->getConnection()->getName() ?? '', + 'table' => $model->getConnection()->getTablePrefix() . $model->getTable(), + 'policy' => $this->getPolicy($model) ?? '', + 'attributes' => $this->getAttributes($model), + 'relations' => $this->getRelations($model), + 'events' => $this->getEvents($model), + 'observers' => $this->getObservers($model), + 'collection' => $this->getCollectedBy($model), + 'builder' => $this->getBuilder($model), + ]; + } + + /** + * Get the column attributes for the given model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return Collection> + */ + protected function getAttributes($model) + { + $connection = $model->getConnection(); + assert($connection instanceof Connection); + + $schema = $connection->getSchemaBuilder(); + $table = $model->getTable(); + $tableData = $schema->getTable($table); + $columns = $schema->getColumns($table); + $indexes = $schema->getIndexes($table); + + $columns = $this->addSystemAttributes($columns, $tableData); + + /* @phpstan-ignore-next-line */ + return collect($columns) + ->map(fn($column) => [ + 'name' => $column['name'], + 'type' => $column['type'], + 'increments' => $column['auto_increment'] ?? null, + 'nullable' => $column['nullable'] ?? null, + 'default' => $this->getColumnDefault($column, $model) ?? null, + 'unique' => $this->columnIsUnique($column['name'], $indexes), + 'fillable' => $model->isFillable($column['name']), + 'computed' => $this->columnIsComputed($column['name'], $tableData), + 'hidden' => $this->attributeIsHidden($column['name'], $model), + 'appended' => null, + 'cast' => $this->getCastType($column['name'], $model), + ]) + ->merge($this->getVirtualAttributes($model, $columns)); + } + + /** + * Get the default value for the given column. + * + * @param array $column + * @param \Illuminate\Database\Eloquent\Model $model + * @return mixed|null + */ + protected function getColumnDefault($column, $model) + { + $attributeDefault = $model->getAttributes()[$column['name']] ?? null; + + return enum_value($attributeDefault, $column['default'] ?? null); + } + + /** + * Determine if the given attribute is unique. + * + * @param string $column + * @param mixed[] $indexes + * @return bool + */ + protected function columnIsUnique($column, $indexes) + { + return collect($indexes)->contains( + fn($index) => count($index['fields']) === 1 && $index['fields'][0] === $column && $index['unique'], + ); + } + + /** + * @param string $name + * @param array $tableData + * @return bool + */ + protected function columnIsComputed($name, $tableData) + { + $computedValues = (new Collection($tableData['computedValues']))->pluck('name')->toArray(); + + return in_array($name, $computedValues); + } + + /** + * @param mixed[] $columns + * @param mixed[] $tableData + * @return mixed[] + */ + protected function addSystemAttributes(array $columns, $tableData) + { + // edges add _from, _to + if ($tableData['type'] === 3) { + array_unshift( + $columns, + [ + 'name' => '_to', + 'type' => 'string', + 'nullable' => false, + ], + ); + array_unshift( + $columns, + [ + 'name' => '_from', + 'type' => 'string', + 'nullable' => false, + ], + ); + } + + // Prepend id, + array_unshift( + $columns, + [ + 'name' => 'id', + 'type' => $tableData['keyOptions']->type, + 'nullable' => false, + 'allowUserKeys' => $tableData['keyOptions']->allowUserKeys, + 'unique' => true, + ], + ); + + return $columns; + } +} diff --git a/src/Eloquent/Relations/Concerns/IsAranguentRelation.php b/src/Eloquent/Relations/Concerns/IsAranguentRelation.php index d0b0537..12448eb 100644 --- a/src/Eloquent/Relations/Concerns/IsAranguentRelation.php +++ b/src/Eloquent/Relations/Concerns/IsAranguentRelation.php @@ -5,10 +5,31 @@ namespace LaravelFreelancerNL\Aranguent\Eloquent\Relations\Concerns; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Query\Expression; trait IsAranguentRelation { + /** + * Get all of the primary keys for an array of models. + * + * @param array $models + * @param string|null $key + * @return array + */ + protected function getKeys(array $models, $key = null) + { + // The original function orders the results associatively by value which means the keys reorder too. + // However, a list of keys with unordered numeric keys will be recognized as an object down the line + // for json casting while we need a list of keys. + + $keys = collect($models)->map(function ($value) use ($key) { + return $key ? $value->getAttribute($key) : $value->getKey(); + })->values()->unique(null, true)->all(); + + sort($keys); + + return $keys; + } + /** * Add the constraints for a relationship count query. * @@ -19,7 +40,6 @@ public function getRelationExistenceCountQuery(Builder $query, Builder $parentQu return $this->getRelationExistenceQuery( $query, $parentQuery, - new Expression('*'), ); } } diff --git a/src/Exceptions/QueryException.php b/src/Exceptions/QueryException.php index d665189..e9da997 100644 --- a/src/Exceptions/QueryException.php +++ b/src/Exceptions/QueryException.php @@ -10,10 +10,12 @@ class QueryException extends IlluminateQueryException { /** - * Format the SQL error message. + * Create a new query exception instance. * + * @param string $connectionName * @param string $sql - * @param array $bindings + * @param mixed[] $bindings + * @param \Throwable $previous * @return string */ protected function formatMessage($connectionName, $sql, $bindings, Throwable $previous) diff --git a/src/Exceptions/UniqueConstraintViolationException.php b/src/Exceptions/UniqueConstraintViolationException.php new file mode 100644 index 0000000..8f2faea --- /dev/null +++ b/src/Exceptions/UniqueConstraintViolationException.php @@ -0,0 +1,7 @@ +useFallback()) { diff --git a/src/Providers/CommandServiceProvider.php b/src/Providers/CommandServiceProvider.php index d73f802..cfacb90 100644 --- a/src/Providers/CommandServiceProvider.php +++ b/src/Providers/CommandServiceProvider.php @@ -4,6 +4,10 @@ namespace LaravelFreelancerNL\Aranguent\Providers; +use LaravelFreelancerNL\Aranguent\Console\ShowCommand; +use LaravelFreelancerNL\Aranguent\Console\ShowModelCommand; +use LaravelFreelancerNL\Aranguent\Console\TableCommand; +use LaravelFreelancerNL\Aranguent\Console\WipeCommand; use LaravelFreelancerNL\Aranguent\Console\DbCommand; use Illuminate\Database\Console\DbCommand as IlluminateDbCommand; use LaravelFreelancerNL\Aranguent\Console\ModelMakeCommand; @@ -21,6 +25,10 @@ class CommandServiceProvider extends ServiceProvider protected $commands = [ 'ModelMake' => ModelMakeCommand::class, 'Db' => DbCommand::class, + 'DbWipe' => WipeCommand::class, + 'DbShow' => ShowCommand::class, + 'DbTable' => TableCommand::class, + 'ShowModel' => ShowModelCommand::class, ]; @@ -39,27 +47,35 @@ public function register() * * @param string[] $commands * @return void + * + * @SuppressWarnings("PHPMD.ElseExpression") */ protected function registerCommands(array $commands) { - foreach (array_keys($commands) as $command) { - $this->{"register{$command}Command"}(); + foreach ($commands as $commandName => $command) { + $method = "register{$commandName}Command"; + + if (method_exists($this, $method)) { + $this->{$method}(); + } else { + $this->app->singleton($command); + } } $this->commands(array_values($commands)); } - protected function registerModelMakeCommand(): void + protected function registerDbCommand(): void { - $this->app->singleton(ModelMakeCommand::class, function ($app) { - return new ModelMakeCommand($app['files']); + $this->app->extend(IlluminateDbCommand::class, function () { + return new DbCommand(); }); } - protected function registerDbCommand(): void + protected function registerModelMakeCommand(): void { - $this->app->extend(IlluminateDbCommand::class, function () { - return new DbCommand(); + $this->app->singleton(ModelMakeCommand::class, function ($app) { + return new ModelMakeCommand($app['files']); }); } diff --git a/src/Providers/MigrationServiceProvider.php b/src/Providers/MigrationServiceProvider.php index 919b412..1f65814 100644 --- a/src/Providers/MigrationServiceProvider.php +++ b/src/Providers/MigrationServiceProvider.php @@ -7,6 +7,7 @@ use Illuminate\Database\Migrations\Migrator; use Illuminate\Database\MigrationServiceProvider as IlluminateMigrationServiceProvider; use LaravelFreelancerNL\Aranguent\Console\Concerns\ArangoCommands; +use LaravelFreelancerNL\Aranguent\Console\Migrations\FreshCommand; use LaravelFreelancerNL\Aranguent\Console\Migrations\MigrationsConvertCommand; use LaravelFreelancerNL\Aranguent\Console\Migrations\MigrateMakeCommand; use LaravelFreelancerNL\Aranguent\Console\Migrations\MigrateInstallCommand; @@ -28,6 +29,7 @@ class MigrationServiceProvider extends IlluminateMigrationServiceProvider 'Repository' => 'migration.repository', 'MigrateMake' => 'migrate.make', 'MigrateInstall' => MigrateInstallCommand::class, + 'MigrateFresh' => FreshCommand::class, ]; /** @@ -40,7 +42,7 @@ public function __construct($app) { parent::__construct($app); - foreach($this->aliases as $key => $alias) { + foreach ($this->aliases as $key => $alias) { $this->aliases[$key] = $alias; } } @@ -51,6 +53,7 @@ public function boot(): void $this->commands([ MigrateMakeCommand::class, MigrationsConvertCommand::class, + FreshCommand::class, ]); } } @@ -153,6 +156,18 @@ protected function registerMigrationsConvertCommand(): void }); } + /** + * Register the command. + * + * @return void + */ + protected function registerMigrateFreshCommand() + { + $this->app->singleton(FreshCommand::class, function ($app) { + return new FreshCommand($app['migrator']); + }); + } + /** * @return string[] */ diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 58cdf02..e47355e 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -115,12 +115,11 @@ class Builder extends IlluminateQueryBuilder * Create a new query builder instance. */ public function __construct( - IlluminateConnectionInterface $connection, - IlluminateQueryGrammar $grammar = null, - IlluminateProcessor $processor = null, - AQB $aqb = null, + IlluminateConnectionInterface $connection, + ?IlluminateQueryGrammar $grammar = null, + ?IlluminateProcessor $processor = null, + ?AQB $aqb = null, ) { - assert($connection instanceof IlluminateConnectionInterface); assert($processor instanceof IlluminateProcessor); parent::__construct($connection, $grammar, $processor); diff --git a/src/Query/Concerns/BuildsGroups.php b/src/Query/Concerns/BuildsGroups.php index 8929433..b49fbb5 100644 --- a/src/Query/Concerns/BuildsGroups.php +++ b/src/Query/Concerns/BuildsGroups.php @@ -56,6 +56,8 @@ public function groupByRaw($aql, array $bindings = []) public function cleanGroupVariables(): void { + // FIXME: check for possible expressions instead of strings. + /* @phpstan-ignore-next-line */ $this->tableAliases = array_diff($this->tableAliases, $this->groupVariables ?? []); $this->groupVariables = null; } @@ -144,7 +146,7 @@ public function havingRaw($sql, array $bindings = [], $boolean = 'and') public function forNestedWhere($aliases = []) { $query = $this->newQuery(); - foreach($aliases as $alias) { + foreach ($aliases as $alias) { $query->groups[] = $alias; } @@ -160,9 +162,7 @@ public function forNestedWhere($aliases = []) */ public function havingNested(Closure $callback, $boolean = 'and') { - $callback($query = $this->forNestedWhere($this->groups)); - - + $callback($query = $this->forNestedWhere($this->groups ?? [])); return $this->addNestedHavingQuery($query, $boolean); } @@ -176,7 +176,7 @@ public function havingNested(Closure $callback, $boolean = 'and') */ public function addNestedHavingQuery($query, $boolean = 'and') { - if (count($query->havings)) { + if (count($query->havings ?? [])) { $type = 'Nested'; $this->havings[] = compact('type', 'query', 'boolean'); @@ -197,7 +197,7 @@ public function addNestedHavingQuery($query, $boolean = 'and') * @param bool $not * @return $this * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ public function havingBetween($column, iterable $values, $boolean = 'and', $not = false) { diff --git a/src/Query/Concerns/BuildsJoins.php b/src/Query/Concerns/BuildsJoins.php index 989a4cd..caf419d 100644 --- a/src/Query/Concerns/BuildsJoins.php +++ b/src/Query/Concerns/BuildsJoins.php @@ -23,7 +23,7 @@ trait BuildsJoins * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second * @return $this * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ public function rightJoin($table, $first, $operator = null, $second = null) { @@ -40,7 +40,7 @@ public function rightJoin($table, $first, $operator = null, $second = null) * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second * @return $this * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ public function rightJoinSub($query, $as, $first, $operator = null, $second = null) { @@ -56,7 +56,7 @@ public function rightJoinSub($query, $as, $first, $operator = null, $second = nu * @param \Illuminate\Contracts\Database\Query\Expression|string $second * @return $this * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ public function rightJoinWhere($table, $first, $operator, $second) { @@ -69,7 +69,7 @@ public function rightJoinWhere($table, $first, $operator, $second) * * The boolean argument flag is part of this method's API in Laravel. * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @param mixed $table * @param Closure|string $first @@ -117,7 +117,7 @@ public function join($table, $first, $operator = null, $second = null, $type = ' * * @throws \InvalidArgumentException * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ public function joinSub($query, $as, $first, $operator = null, $second = null, $type = 'inner', $where = false): IlluminateQueryBuilder { @@ -182,4 +182,28 @@ protected function newJoinClause(IlluminateQueryBuilder $parentQuery, $type, $ta return new JoinClause($parentQuery, $type, $table); } + /** + * Add a lateral join clause to the query. + * + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query + * @param string $as + * @param string $type + * @return $this + */ + public function joinLateral($query, string $as, string $type = 'inner') + { + assert($query instanceof Builder); + + $query->importTableAliases($this); + $query->importTableAliases([$as => $as]); + $this->importTableAliases($query); + + [$query] = $this->createSub($query); + + $expression = $query . ' as ' . $this->grammar->wrapTable($as); + + $this->joins[] = $this->newJoinLateralClause($this, $type, new Expression($expression)); + + return $this; + } } diff --git a/src/Query/Concerns/BuildsSearches.php b/src/Query/Concerns/BuildsSearches.php index 9ed55b5..acaaf08 100644 --- a/src/Query/Concerns/BuildsSearches.php +++ b/src/Query/Concerns/BuildsSearches.php @@ -26,7 +26,7 @@ public function searchView( ): IlluminateQueryBuilder { assert($this->grammar instanceof Grammar); - if(!is_array($fields)) { + if (!is_array($fields)) { $fields = Arr::wrap($fields); } $fields = $this->grammar->convertJsonFields($fields); diff --git a/src/Query/Concerns/BuildsSelects.php b/src/Query/Concerns/BuildsSelects.php index 3360f43..ba99513 100644 --- a/src/Query/Concerns/BuildsSelects.php +++ b/src/Query/Concerns/BuildsSelects.php @@ -229,7 +229,7 @@ public function inRandomOrder($seed = '') * @param bool $all * @return $this * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ public function union($query, $all = false) { diff --git a/src/Query/Concerns/BuildsSubqueries.php b/src/Query/Concerns/BuildsSubqueries.php index 22cba48..02d5475 100644 --- a/src/Query/Concerns/BuildsSubqueries.php +++ b/src/Query/Concerns/BuildsSubqueries.php @@ -28,7 +28,7 @@ trait BuildsSubqueries * @param \Closure|IlluminateQueryBuilder|IlluminateEloquentBuilder|string $query * @return array * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ public function createSub($query, bool $returnSingleValue = false) { diff --git a/src/Query/Concerns/BuildsUpdates.php b/src/Query/Concerns/BuildsUpdates.php index 56282c5..e2688bf 100644 --- a/src/Query/Concerns/BuildsUpdates.php +++ b/src/Query/Concerns/BuildsUpdates.php @@ -4,6 +4,7 @@ namespace LaravelFreelancerNL\Aranguent\Query\Concerns; +use Closure; use Illuminate\Database\Query\Expression; use Illuminate\Support\Arr; use InvalidArgumentException; @@ -21,7 +22,7 @@ trait BuildsUpdates */ protected function prepareValuesForUpdate(array $values) { - foreach($values as $key => $value) { + foreach ($values as $key => $value) { if ($value instanceof Expression) { $values[$key] = $value->getValue($this->grammar); @@ -64,13 +65,19 @@ public function update(array $values) * Insert or update a record matching the attributes, and fill it with values. * * @param array $attributes - * @param array $values + * @param array|callable $values * @return bool * @throws BindException */ - public function updateOrInsert(array $attributes, array $values = []) + public function updateOrInsert(array $attributes, array|callable $values = []) { - if (!$this->where($attributes)->exists()) { + $exists = $this->where($attributes)->exists(); + + if ($values instanceof Closure) { + $values = $values($exists); + } + + if (! $exists) { $this->bindings['where'] = []; return $this->insert(array_merge($attributes, $values)); } @@ -153,13 +160,13 @@ public function upsert(array $values, $uniqueBy, $update = null) $values = [$values]; } - foreach($values as $key => $value) { + foreach ($values as $key => $value) { $values[$key] = $this->grammar->convertJsonFields($value); $values[$key] = $this->convertIdToKey($values[$key]); $values[$key] = Arr::undot($values[$key]); } - foreach($values as $key => $value) { + foreach ($values as $key => $value) { foreach ($value as $dataKey => $data) { $values[$key][$dataKey] = $this->bindValue($data, 'upsert'); } diff --git a/src/Query/Concerns/BuildsWheres.php b/src/Query/Concerns/BuildsWheres.php index d516543..7b05155 100644 --- a/src/Query/Concerns/BuildsWheres.php +++ b/src/Query/Concerns/BuildsWheres.php @@ -6,6 +6,7 @@ use Carbon\CarbonPeriod; use Closure; +use DateTimeInterface; use Illuminate\Contracts\Database\Query\ConditionExpression; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Builder as IlluminateEloquentBuilder; @@ -27,7 +28,7 @@ trait BuildsWheres * @param string $boolean * @return $this * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ public function whereFullText($columns, $value, array $options = [], $boolean = 'and') { @@ -40,14 +41,14 @@ public function whereFullText($columns, $value, array $options = [], $boolean = /** * Prepare the value and operator for a where clause. * - * @param float|int|string|null $value + * @param DateTimeInterface|float|int|string|null $value * @param string|null $operator * @param bool $useDefault * @return array * * @throws \InvalidArgumentException * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ public function prepareValueAndOperator($value, $operator, $useDefault = false) { @@ -92,7 +93,7 @@ public function validateOperator(mixed $operator, mixed $value): array if ($this->invalidOperator($operator)) { [$value, $operator] = [$operator, '==']; } - return array($value, $operator); + return [$value, $operator]; } /** @@ -157,7 +158,7 @@ public function addNestedWhereQuery($query, $boolean = 'and') * @param bool $not * @return $this * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ public function addWhereExistsQuery(IlluminateQueryBuilder $query, $boolean = 'and', $not = false) { @@ -195,8 +196,8 @@ public function mergeWheres($wheres, $bindings) * @param string $boolean * @return IlluminateQueryBuilder * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings("PHPMD.CyclomaticComplexity") + * @SuppressWarnings("PHPMD.NPathComplexity") */ public function where($column, $operator = null, $value = null, $boolean = 'and') { @@ -286,7 +287,7 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' * @param bool $not * @return IlluminateQueryBuilder * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ public function whereBetween($column, iterable $values, $boolean = 'and', $not = false) { @@ -356,7 +357,7 @@ public function whereColumn($first, $operator = null, $second = null, $boolean = * @param bool $not * @return IlluminateQueryBuilder * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ public function whereIn($column, $values, $boolean = 'and', $not = false) { @@ -390,7 +391,7 @@ public function whereIn($column, $values, $boolean = 'and', $not = false) /** * Add a "where JSON contains" clause to the query. * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @param string $column * @param mixed $value @@ -435,6 +436,29 @@ public function whereJsonLength($column, $operator, $value = null, $boolean = 'a return $this; } + /** + * Add a "where like" clause to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param string $value + * @param bool $caseSensitive + * @param string $boolean + * @param bool $not + * @return $this + * + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") + */ + public function whereLike($column, $value, $caseSensitive = false, $boolean = 'and', $not = false) + { + $type = 'Like'; + + $value = $this->bindValue($value); + + $this->wheres[] = compact('type', 'column', 'value', 'caseSensitive', 'boolean', 'not'); + + return $this; + } + /** * Add a "where null" clause to the query. * @@ -443,7 +467,7 @@ public function whereJsonLength($column, $operator, $value = null, $boolean = 'a * @param bool $not * @return $this * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ public function whereNull($columns, $boolean = 'and', $not = false) { diff --git a/src/Query/Concerns/CompilesAggregates.php b/src/Query/Concerns/CompilesAggregates.php index 27ecca6..be3edc2 100644 --- a/src/Query/Concerns/CompilesAggregates.php +++ b/src/Query/Concerns/CompilesAggregates.php @@ -40,7 +40,7 @@ protected function compileAvg(Builder $query, array $aggregate) /** * Compile AQL for count aggregate. * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") * @param Builder $query * @return string */ diff --git a/src/Query/Concerns/CompilesColumns.php b/src/Query/Concerns/CompilesColumns.php index 8270da8..fb0bca1 100644 --- a/src/Query/Concerns/CompilesColumns.php +++ b/src/Query/Concerns/CompilesColumns.php @@ -11,6 +11,39 @@ trait CompilesColumns { + /** + * @param mixed[] $returnAttributes + * @param mixed[] $returnDocs + * @param Builder $query + * @return mixed[] + */ + public function processEmptyReturnValues(array $returnAttributes, array $returnDocs, Builder $query): array + { + if (empty($returnAttributes) && empty($returnDocs)) { + $returnDocs[] = (string) $query->getTableAlias($query->from); + + if ($query->joins !== null) { + $returnDocs = $this->mergeJoinResults($query, $returnDocs); + } + } + return $returnDocs; + } + + /** + * @param Builder $query + * @param mixed[] $returnDocs + * @param mixed[] $returnAttributes + * @return mixed[] + */ + public function processAggregateReturnValues(Builder $query, array $returnDocs, array $returnAttributes): array + { + if ($query->aggregate !== null && $query->unions === null) { + $returnDocs = []; + $returnAttributes = ['aggregate' => 'aggregateResult']; + } + return [$returnDocs, $returnAttributes]; + } + /** * Compile the "select *" portion of the query. * @@ -43,7 +76,7 @@ protected function compileColumns(IlluminateQueryBuilder $query, $columns) * @return array * @throws Exception * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings("PHPMD.CyclomaticComplexity") */ protected function prepareColumns(IlluminateQueryBuilder $query, array $columns) { @@ -91,7 +124,7 @@ protected function prepareColumns(IlluminateQueryBuilder $query, array $columns) /** * @throws Exception */ - protected function normalizeColumn(IlluminateQueryBuilder $query, mixed $column, string $table = null): mixed + protected function normalizeColumn(IlluminateQueryBuilder $query, mixed $column, ?string $table = null): mixed { assert($query instanceof Builder); @@ -137,7 +170,7 @@ protected function normalizeColumn(IlluminateQueryBuilder $query, mixed $column, * @return array * @throws Exception */ - protected function normalizeStringColumn(Builder $query, int|string $key, string $column, string $table = null): array + protected function normalizeStringColumn(Builder $query, int|string $key, string $column, ?string $table = null): array { [$column, $alias] = $query->extractAlias($column, $key); @@ -158,7 +191,7 @@ protected function normalizeStringColumn(Builder $query, int|string $key, string * @param string|null $table * @return string */ - protected function normalizeColumnReferences(IlluminateQueryBuilder $query, string $column, string $table = null): string + protected function normalizeColumnReferences(IlluminateQueryBuilder $query, string $column, ?string $table = null): string { assert($query instanceof Builder); @@ -172,7 +205,6 @@ protected function normalizeColumnReferences(IlluminateQueryBuilder $query, stri $references = explode('.', $column); - $tableAlias = $query->getTableAlias($references[0]); if (isset($tableAlias)) { @@ -184,6 +216,7 @@ protected function normalizeColumnReferences(IlluminateQueryBuilder $query, stri array_unshift($references, $tableAlias); } + // geen tableAlias, table is parent...waarom geen tableAlias? if ($tableAlias === null && array_key_exists($table, $query->tableAliases)) { array_unshift($references, $query->tableAliases[$table]); } @@ -211,7 +244,7 @@ protected function cleanAlias(IlluminateQueryBuilder $query, int|null|string $al $elements = explode('.', $alias); - if( + if ( !$query->isTable($elements[0]) && !$query->isVariable($elements[0]) ) { @@ -235,19 +268,10 @@ protected function determineReturnValues(IlluminateQueryBuilder $query, $returnA assert($query instanceof Builder); // If nothing was specifically requested, we return everything. - if (empty($returnAttributes) && empty($returnDocs)) { - $returnDocs[] = (string) $query->getTableAlias($query->from); - - if ($query->joins !== null) { - $returnDocs = $this->mergeJoinResults($query, $returnDocs); - } - } + $returnDocs = $this->processEmptyReturnValues($returnAttributes, $returnDocs, $query); // Aggregate functions only return the aggregate, so we can clear out everything else. - if ($query->aggregate !== null) { - $returnDocs = []; - $returnAttributes = ['aggregate' => 'aggregateResult']; - } + list($returnDocs, $returnAttributes) = $this->processAggregateReturnValues($query, $returnDocs, $returnAttributes); // Return a single value for certain subqueries if ( @@ -289,6 +313,10 @@ protected function mergeJoinResults(IlluminateQueryBuilder $query, $returnDocs = { assert($query instanceof Builder); + if (!is_array($query->joins)) { + return $returnDocs; + } + foreach ($query->joins as $join) { $tableAlias = $query->getTableAlias($join->table); diff --git a/src/Query/Concerns/CompilesDataManipulations.php b/src/Query/Concerns/CompilesDataManipulations.php index 9d0a7b7..b7dc0f5 100644 --- a/src/Query/Concerns/CompilesDataManipulations.php +++ b/src/Query/Concerns/CompilesDataManipulations.php @@ -16,7 +16,7 @@ trait CompilesDataManipulations * @param string|null $bindVar * @return string */ - public function compileInsert(Builder|IlluminateQueryBuilder $query, array $values, string $bindVar = null) + public function compileInsert(Builder|IlluminateQueryBuilder $query, array $values, ?string $bindVar = null) { $table = $this->prefixTable($query->from); @@ -41,7 +41,7 @@ public function compileInsert(Builder|IlluminateQueryBuilder $query, array $valu * @param string|null $bindVar * @return string */ - public function compileInsertGetId(IlluminateQueryBuilder $query, $values, $sequence = '_key', string $bindVar = null) + public function compileInsertGetId(IlluminateQueryBuilder $query, $values, $sequence = '_key', ?string $bindVar = null) { $table = $this->prefixTable($query->from); @@ -68,7 +68,7 @@ public function compileInsertGetId(IlluminateQueryBuilder $query, $values, $sequ * @param array $values * @return string */ - public function compileInsertOrIgnore(IlluminateQueryBuilder $query, array $values, string $bindVar = null) + public function compileInsertOrIgnore(IlluminateQueryBuilder $query, array $values, ?string $bindVar = null) { $table = $this->prefixTable($query->from); @@ -107,7 +107,7 @@ public function compileInsertUsing(IlluminateQueryBuilder $query, array $columns if ($insertDoc === '') { $insertValues = []; - foreach($columns as $column) { + foreach ($columns as $column) { $insertValues[$column] = $this->normalizeColumnReferences($query, $column, 'docs'); } $insertDoc = $this->generateAqlObject($insertValues); @@ -122,6 +122,41 @@ public function compileInsertUsing(IlluminateQueryBuilder $query, array $columns return $aql; } + /** + * Compile an insert statement using a subquery into SQL. + * + * @param IlluminateQueryBuilder $query + * @param array $columns + * @param string $sql + * @return string + */ + public function compileInsertOrIgnoreUsing(IlluminateQueryBuilder $query, array $columns, string $sql) + { + $table = $this->wrapTable($query->from); + + $insertDoc = ''; + if (empty($columns) || $columns === ['*']) { + $insertDoc = 'docDoc'; + } + + + if ($insertDoc === '') { + $insertValues = []; + foreach ($columns as $column) { + $insertValues[$column] = $this->normalizeColumnReferences($query, $column, 'docs'); + } + $insertDoc = $this->generateAqlObject($insertValues); + } + + $aql = /** @lang AQL */ 'LET docs = ' . $sql + . ' FOR docDoc IN docs' + . ' INSERT ' . $insertDoc . ' INTO ' . $table + . ' OPTIONS { ignoreErrors: true }' + . ' RETURN NEW._key'; + + return $aql; + } + /** * @param array $values * @return string @@ -129,7 +164,7 @@ public function compileInsertUsing(IlluminateQueryBuilder $query, array $columns protected function createUpdateObject($values) { $valueStrings = []; - foreach($values as $key => $value) { + foreach ($values as $key => $value) { if (is_array($value)) { $valueStrings[] = $key . ': ' . $this->createUpdateObject($value); @@ -188,19 +223,19 @@ public function compileUpdate(IlluminateQueryBuilder $query, array|string $value public function compileUpsert(IlluminateQueryBuilder $query, array $values, array $uniqueBy, array $update) { $searchFields = []; - foreach($uniqueBy as $field) { + foreach ($uniqueBy as $field) { $searchFields[$field] = 'doc.' . $field; } $searchObject = $this->generateAqlObject($searchFields); $updateFields = []; - foreach($update as $field) { + foreach ($update as $field) { $updateFields[$field] = 'doc.' . $field; } $updateObject = $this->generateAqlObject($updateFields); $valueObjects = []; - foreach($values as $data) { + foreach ($values as $data) { $valueObjects[] = $this->generateAqlObject($data); } diff --git a/src/Query/Concerns/CompilesFilters.php b/src/Query/Concerns/CompilesFilters.php index d38b770..047d3ce 100644 --- a/src/Query/Concerns/CompilesFilters.php +++ b/src/Query/Concerns/CompilesFilters.php @@ -42,7 +42,7 @@ protected function compileWheresToArray($query) * @param array $aql * @return string * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ protected function concatenateWhereClauses($query, $aql) { @@ -357,7 +357,7 @@ protected function filterJsonLength(IlluminateQueryBuilder $query, array $filter * @param array $filter * @return mixed * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ protected function filterExpression(IlluminateQueryBuilder $query, $filter) { @@ -405,6 +405,36 @@ protected function filterYear(IlluminateQueryBuilder $query, $filter) return implode(' ', $predicate); } + /** + * Compile a filter month clause. + * + * @param IlluminateQueryBuilder $query + * @param array $filter + * @return string + * @throws \Exception + */ + protected function filterLike(IlluminateQueryBuilder $query, $filter) + { + $column = $this->normalizeColumn($query, $filter['column']); + $value = $this->parameter($filter['value']); + + $predicate = []; + + $filter = $this->normalizeOperator($filter); + + $predicate[0] = ($filter['caseSensitive']) ? $column : 'LOWER(' . $column . ')'; + $predicate[1] = 'LIKE'; + $predicate[2] = ($filter['caseSensitive']) ? $value : 'LOWER(' . $value . ')'; + + $result = implode(' ', $predicate); + + if ($filter['not']) { + $result = 'NOT (' . $result . ')'; + } + + return $result; + } + /** * Compile a filter month clause. * @@ -496,7 +526,7 @@ protected function filterSub(IlluminateQueryBuilder $query, $filter) * @param array $filter * @return string * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ protected function filterExists(IlluminateQueryBuilder $query, $filter) { @@ -510,7 +540,7 @@ protected function filterExists(IlluminateQueryBuilder $query, $filter) * @param array $filter * @return string * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ protected function filterNotExists(IlluminateQueryBuilder $query, $filter) { @@ -524,7 +554,7 @@ protected function filterNotExists(IlluminateQueryBuilder $query, $filter) * @param array $filter * @return string * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ protected function filterNested(IlluminateQueryBuilder $query, $filter) { diff --git a/src/Query/Concerns/CompilesGroups.php b/src/Query/Concerns/CompilesGroups.php index 4fe54ad..b5ad17c 100644 --- a/src/Query/Concerns/CompilesGroups.php +++ b/src/Query/Concerns/CompilesGroups.php @@ -64,7 +64,7 @@ protected function keepColumns(IlluminateQueryBuilder $query, $groups) return []; } $tempGroups = []; - foreach($groups as $group) { + foreach ($groups as $group) { if ($group instanceof Expression) { $tempGroups[] = $this->extractGroupVariable($group); continue; diff --git a/src/Query/Concerns/CompilesJoins.php b/src/Query/Concerns/CompilesJoins.php index ae133e8..264dda5 100644 --- a/src/Query/Concerns/CompilesJoins.php +++ b/src/Query/Concerns/CompilesJoins.php @@ -20,13 +20,16 @@ public function extractTableAndAlias(Builder $query, $join): array { if ($join->table instanceof Expression) { $tableParts = []; - preg_match("/(^.*) as (.*?)$/", (string) $join->table->getValue($query->grammar), $tableParts); - $table = $tableParts[1]; - $alias = $tableParts[2]; - $query->registerTableAlias($join->table, $alias); + if (preg_match("/(^.*) as (.*?)$/", (string) $join->table->getValue($query->grammar), $tableParts)) { + $table = $tableParts[1]; + $alias = $tableParts[2]; + + $query->registerTableAlias($join->table, $alias); + + return [$table, $alias]; + } - return [$table, $alias]; } $table = (string) $this->wrapTable($join->table); diff --git a/src/Query/Concerns/CompilesUnions.php b/src/Query/Concerns/CompilesUnions.php index 8bd5286..4870077 100644 --- a/src/Query/Concerns/CompilesUnions.php +++ b/src/Query/Concerns/CompilesUnions.php @@ -18,13 +18,19 @@ trait CompilesUnions */ protected function compileUnions(IlluminateBuilder $query, $firstQuery = '') { + if (!is_array($query->unions)) { + return ''; + } + $unionResultsId = 'union' . $query->getQueryId() . 'Results'; $unionDocId = 'union' . $query->getQueryId() . 'Result'; $query->registerTableAlias($unionResultsId, $unionDocId); $firstQuery = $this->wrapSubquery($firstQuery); + $unions = ''; + foreach ($query->unions as $union) { $prefix = ($unions !== '') ? $unions : $firstQuery; $unions = $this->compileUnion($union, $prefix); @@ -33,8 +39,6 @@ protected function compileUnions(IlluminateBuilder $query, $firstQuery = '') $aql = 'LET ' . $unionResultsId . ' = ' . $unions . ' FOR ' . $unionDocId . ' IN ' . $unionResultsId; - // Union groups - if (!empty($query->unionOrders)) { $aql .= ' ' . $this->compileOrders($query, $query->unionOrders, $unionResultsId); } @@ -47,7 +51,17 @@ protected function compileUnions(IlluminateBuilder $query, $firstQuery = '') $aql .= ' ' . $this->compileLimit($query, $query->unionLimit); } - // Union aggregates? + if ($query->aggregate !== null) { + $originalFrom = $query->from; + $query->from = $unionResultsId; + + $aql .= ' ' . $this->compileAggregate($query, $query->aggregate); + + $query->from = $originalFrom; + + return $aql . ' RETURN { `aggregate`: aggregateResult }'; + } + return $aql . ' RETURN ' . $unionDocId; } diff --git a/src/Query/Concerns/ConvertsIdToKey.php b/src/Query/Concerns/ConvertsIdToKey.php index 59ab076..d3a48d6 100644 --- a/src/Query/Concerns/ConvertsIdToKey.php +++ b/src/Query/Concerns/ConvertsIdToKey.php @@ -9,7 +9,7 @@ trait ConvertsIdToKey public function convertIdToKey(mixed $data): mixed { if (is_array($data) && array_is_list($data)) { - foreach($data as $key => $value) { + foreach ($data as $key => $value) { $data[$key] = $this->convertIdInString($value); } return $data; diff --git a/src/Query/Concerns/HandlesAliases.php b/src/Query/Concerns/HandlesAliases.php index 5a0eee8..0d081c2 100644 --- a/src/Query/Concerns/HandlesAliases.php +++ b/src/Query/Concerns/HandlesAliases.php @@ -45,7 +45,7 @@ public function convertColumnId(array|string|Expression $column): array|string|E * * @throws Exception */ - public function extractAlias(string $entity, int|string $key = null): array + public function extractAlias(string $entity, int|null|string $key = null): array { $results = preg_split("/\sas\s/i", $entity); @@ -80,6 +80,10 @@ public function getTableAlias(string|Expression $table): float|int|null|string $table = 'Expression' . spl_object_id($table); } + if ($this->isTableAlias($table)) { + return $table; + } + if (!isset($this->tableAliases[$table])) { return null; } @@ -155,7 +159,7 @@ public function prefixAlias(string $target, string $value): string /** * @throws Exception */ - public function registerColumnAlias(string $column, string $alias = null): bool + public function registerColumnAlias(string $column, ?string $alias = null): bool { if (preg_match("/\sas\s/i", $column)) { [$column, $alias] = $this->extractAlias($column); @@ -170,7 +174,10 @@ public function registerColumnAlias(string $column, string $alias = null): bool return false; } - public function registerTableAlias(string|Expression $table, string $alias = null): string + /** + * @SuppressWarnings("PHPMD.CyclomaticComplexity") + */ + public function registerTableAlias(string|Expression $table, ?string $alias = null): string { if ($table instanceof Expression && $alias !== null) { $table = 'Expression' . spl_object_id($table); @@ -182,12 +189,13 @@ public function registerTableAlias(string|Expression $table, string $alias = nul } /** @phpstan-ignore-next-line */ - if ($alias == null && stripos($table, ' as ') !== false) { + if ($alias == null && is_string($table) && stripos($table, ' as ') !== false) { $tableParts = []; - /** @phpstan-ignore-next-line */ - preg_match("/(^.*) as (.*?)$/", $table, $tableParts); - $table = $tableParts[1]; - $alias = $tableParts[2]; + + if (preg_match("/(^.*) as (.*?)$/", $table, $tableParts)) { + $table = $tableParts[1]; + $alias = $tableParts[2]; + } } if ($alias == null) { diff --git a/src/Query/Concerns/HandlesAqlGrammar.php b/src/Query/Concerns/HandlesAqlGrammar.php index 07eeaa0..07ebf37 100644 --- a/src/Query/Concerns/HandlesAqlGrammar.php +++ b/src/Query/Concerns/HandlesAqlGrammar.php @@ -116,7 +116,7 @@ public function quoteString($value) * @param Array|Expression|string $value * @return array|float|int|string * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") */ public function wrap($value) { @@ -125,7 +125,7 @@ public function wrap($value) } if (is_array($value)) { - foreach($value as $key => $subvalue) { + foreach ($value as $key => $subvalue) { $value[$key] = $this->wrap($subvalue); } return $value; @@ -148,10 +148,10 @@ public function wrap($value) * @param Expression|string $table * @return float|int|string */ - public function wrapTable($table) + public function wrapTable($table, $prefix = null) { if (!$table instanceof Expression) { - $wrappedTable = $this->wrap($this->tablePrefix . $table); + $wrappedTable = $this->wrap(($prefix ?? $this->tablePrefix) . $table); assert(!is_array($wrappedTable)); @@ -211,7 +211,6 @@ public function wrapSubquery(string $subquery): string public function generateAqlObject(array $data): string { $data = Arr::undot($data); - ray($this->generateAqlObjectString($data)); return $this->generateAqlObjectString($data); } @@ -221,7 +220,7 @@ public function generateAqlObject(array $data): string */ protected function generateAqlObjectString(array $data): string { - foreach($data as $key => $value) { + foreach ($data as $key => $value) { $prefix = $this->wrapAttribute($key) . ': '; if (is_numeric($key)) { @@ -270,7 +269,7 @@ public function substituteBindingsIntoRawSql($aql, $bindings) $bindings = array_reverse($bindings); - foreach($bindings as $key => $value) { + foreach ($bindings as $key => $value) { $pattern = '/(@' . $key . ')(?![^a-zA-Z_ ,\}\]])/'; $aql = (string) preg_replace( $pattern, @@ -290,7 +289,7 @@ public function substituteBindingsIntoRawSql($aql, $bindings) */ public function isJsonSelector($value) { - if(!is_string($value)) { + if (!is_string($value)) { return false; } @@ -320,7 +319,7 @@ public function convertJsonFields(mixed $data): mixed */ public function convertJsonValuesToDotNotation(array $fields): array { - foreach($fields as $key => $value) { + foreach ($fields as $key => $value) { if ($this->isJsonSelector($value)) { $fields[$key] = str_replace('->', '.', $value); } @@ -334,7 +333,7 @@ public function convertJsonValuesToDotNotation(array $fields): array */ public function convertJsonKeysToDotNotation(array $fields): array { - foreach($fields as $key => $value) { + foreach ($fields as $key => $value) { if ($this->isJsonSelector($key)) { $fields[str_replace('->', '.', $key)] = $value; unset($fields[$key]); diff --git a/src/Query/Concerns/HandlesBindings.php b/src/Query/Concerns/HandlesBindings.php index 16b28e6..f267b3a 100644 --- a/src/Query/Concerns/HandlesBindings.php +++ b/src/Query/Concerns/HandlesBindings.php @@ -78,7 +78,7 @@ protected function getLastBindVariable(string $type = 'where'): string * @param string|null $type * @return void */ - public function importBindings($query, string $type = null): void + public function importBindings($query, ?string $type = null): void { if ($type) { $this->bindings[$type] = array_merge($this->bindings[$type], $query->bindings[$type]); diff --git a/src/Query/Grammar.php b/src/Query/Grammar.php index aaeaadf..54dc477 100644 --- a/src/Query/Grammar.php +++ b/src/Query/Grammar.php @@ -55,11 +55,10 @@ class Grammar extends IlluminateQueryGrammar */ protected $operators = [ '==', '!=', '<', '>', '<=', '>=', - 'LIKE', '~', '!~', - 'IN', 'NOT IN', - 'ALL ==', 'ALL !=', 'ALL <', 'ALL >', 'ALL <=', 'ALL >=', 'ALL IN', - 'ANY ==', 'ANY !=', 'ANY <', 'ANY >', 'ANY <=', 'ANY >=', 'ANY IN', - 'NONE ==', 'NONE !=', 'NONE <', 'NONE >', 'NONE <=', 'NONE >=', 'NONE IN', + 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', '=~', '!~', + 'ALL ==', 'ALL !=', 'ALL <', 'ALL >', 'ALL <=', 'ALL >=', 'ALL IN', 'ALL NOT IN', + 'ANY ==', 'ANY !=', 'ANY <', 'ANY >', 'ANY <=', 'ANY >=', 'ANY IN', 'ANY NOT IN', + 'NONE ==', 'NONE !=', 'NONE <', 'NONE >', 'NONE <=', 'NONE >=', 'NONE IN', 'NONE NOT IN', ]; /** @@ -176,6 +175,10 @@ protected function compileComponents(IlluminateQueryBuilder $query) continue; } + if ($component === 'aggregate' && $query->unions !== null) { + continue; + } + if (isset($query->$component)) { $method = 'compile' . ucfirst($component); @@ -203,7 +206,7 @@ public function compileSelect(IlluminateQueryBuilder $query) // can build the query and concatenate all the pieces together as one. $original = $query->columns; - if (empty($query->columns)) { + if (empty($query->columns) || $query->unions !== null) { $query->columns = ['*']; } @@ -217,15 +220,12 @@ public function compileSelect(IlluminateQueryBuilder $query) ), ); - // if ($query->unions && $query->aggregate) { - // return $this->compileUnionAggregate($query); - // } + $query->columns = $original; + if ($query->unions) { return $this->compileUnions($query, $aql); } - $query->columns = $original; - if ($query->groupVariables !== null) { $query->cleanGroupVariables(); } @@ -264,7 +264,7 @@ protected function compileFrom(IlluminateQueryBuilder $query, $table) * * @param array $options * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ protected function compileFromOptions($options): string { @@ -297,7 +297,7 @@ protected function compilePostIterationVariables(IlluminateQueryBuilder $query, * @param array $variables * @return string * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ protected function compileVariables(IlluminateQueryBuilder $query, array $variables): string { @@ -367,7 +367,7 @@ protected function compileOrdersToArray(IlluminateQueryBuilder $query, $orders, * @param int $offset * @return string * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ protected function compileOffset(IlluminateQueryBuilder $query, $offset) { @@ -383,7 +383,7 @@ protected function compileOffset(IlluminateQueryBuilder $query, $offset) * @param int $limit * @return string * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ protected function compileLimit(IlluminateQueryBuilder $query, $limit) { @@ -420,7 +420,7 @@ public function compileSearch(IlluminateQueryBuilder $query, array $search) } $predicates = []; - foreach($search['fields'] as $field) { + foreach ($search['fields'] as $field) { $predicates[] = $this->normalizeColumn($query, $field) . ' IN TOKENS(' . $search['searchText'] . ', \'' . $search['analyzer'] . '\')'; } diff --git a/src/Query/Processor.php b/src/Query/Processor.php index c3f1d23..7764e4e 100644 --- a/src/Query/Processor.php +++ b/src/Query/Processor.php @@ -12,7 +12,7 @@ class Processor extends IlluminateProcessor /** * Process the results of a "select" query. * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings("PHPMD.UnusedFormalParameter") * * @param array|null $results * @return array diff --git a/src/Schema/Blueprint.php b/src/Schema/Blueprint.php index 4d1eddc..a039b35 100644 --- a/src/Schema/Blueprint.php +++ b/src/Schema/Blueprint.php @@ -9,9 +9,9 @@ use Illuminate\Support\Fluent; use Illuminate\Support\Traits\Macroable; use LaravelFreelancerNL\Aranguent\Connection; -use LaravelFreelancerNL\Aranguent\Schema\Concerns\Columns; -use LaravelFreelancerNL\Aranguent\Schema\Concerns\Indexes; -use LaravelFreelancerNL\Aranguent\Schema\Concerns\Tables; +use LaravelFreelancerNL\Aranguent\Schema\Concerns\ColumnCommands; +use LaravelFreelancerNL\Aranguent\Schema\Concerns\IndexCommands; +use LaravelFreelancerNL\Aranguent\Schema\Concerns\TableCommands; /** * Class Blueprint. @@ -28,9 +28,9 @@ class Blueprint { use Macroable; - use Tables; - use Columns; - use Indexes; + use TableCommands; + use ColumnCommands; + use IndexCommands; /** * The connection that is used by the blueprint. @@ -282,13 +282,9 @@ public function __call($method, $args = []) } } - $autoIncrementMethods = ['increments', 'autoIncrement']; - if (in_array($method, $autoIncrementMethods)) { - $this->setKeyGenerator('autoincrement'); - } - - if ($method === 'uuid') { - $this->setKeyGenerator('uuid'); + $keyMethods = ['autoIncrement', 'bigIncrements', 'increments', 'mediumIncrements', 'tinyIncrements', 'uuid']; + if (in_array($method, $keyMethods)) { + $this->handleKeyCommands($method, $args); } $this->ignoreMethod($method); @@ -296,14 +292,6 @@ public function __call($method, $args = []) return $this; } - protected function setKeyGenerator(string $generator = 'traditional'): void - { - $column = end($this->columns); - if ($column === '_key' || $column === 'id') { - $this->keyGenerator = $generator; - } - } - protected function ignoreMethod(string $method) { $info = []; @@ -318,4 +306,26 @@ public function renameIdField(mixed $fields) return $value === 'id' ? '_key' : $value; }, $fields); } + + /** + * @param mixed[] $options + * @return mixed[] + */ + protected function setKeyOptions($tableOptions) + { + $configuredKeyOptions = config('arangodb.schema.keyOptions'); + + $columnOptions = []; + $columnOptions['type'] = $this->keyGenerator; + + $mergedKeyOptions = (config('arangodb.schema.key_handling.prioritize_configured_key_type')) + ? array_merge($columnOptions, $configuredKeyOptions, $tableOptions) + : array_merge($configuredKeyOptions, $columnOptions, $tableOptions); + + if ($mergedKeyOptions['type'] === 'autoincrement' && $this->incrementOffset !== 0) { + $mergedKeyOptions['offset'] = $this->incrementOffset; + } + + return $mergedKeyOptions; + } } diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index 184d301..6e1be8f 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -12,12 +12,18 @@ use LaravelFreelancerNL\Aranguent\Connection; use LaravelFreelancerNL\Aranguent\Exceptions\QueryException; use LaravelFreelancerNL\Aranguent\Schema\Concerns\HandlesAnalyzers; +use LaravelFreelancerNL\Aranguent\Schema\Concerns\HandlesIndexes; +use LaravelFreelancerNL\Aranguent\Schema\Concerns\HandlesIndexNaming; +use LaravelFreelancerNL\Aranguent\Schema\Concerns\HandlesGraphs; use LaravelFreelancerNL\Aranguent\Schema\Concerns\HandlesViews; use LaravelFreelancerNL\Aranguent\Schema\Concerns\UsesBlueprints; class Builder extends \Illuminate\Database\Schema\Builder { use HandlesAnalyzers; + use HandlesIndexNaming; + use HandlesGraphs; + use HandlesIndexes; use HandlesViews; use UsesBlueprints; @@ -37,6 +43,17 @@ class Builder extends \Illuminate\Database\Schema\Builder */ public $grammar; + + /** + * index prefixes? + */ + public ?bool $prefixIndexes; + + /** + * The table prefix. + */ + public string $prefix; + /** * Create a new database Schema manager. * @@ -49,6 +66,13 @@ public function __construct(Connection $connection) $this->grammar = $connection->getSchemaGrammar(); $this->schemaManager = $connection->getArangoClient()->schema(); + + $this->prefixIndexes = $this->connection->getConfig('prefix_indexes'); + + $this->prefix = $this->prefixIndexes + ? $this->connection->getConfig('prefix') + : ''; + } /** @@ -78,24 +102,44 @@ public function dropIfExists($table): void /** * Get all the tables for the database; excluding ArangoDB system collections * + * @param string $name + * @return array + * + * @throws ArangoException + */ + public function getTable($name): array + { + return (array) $this->schemaManager->getCollectionStatistics($name); + } + + /** + * Get all the tables for the database; including ArangoDB system tables + * * @return array * * @throws ArangoException */ public function getAllTables(): array { - return $this->schemaManager->getCollections(true); + return $this->mapResultsToArray( + $this->schemaManager->getCollections(false), + ); } /** * Get the tables that belong to the database. * - * @return array + * @param string|string[]|null $schema + * @return array * @throws ArangoException */ - public function getTables() + public function getTables($schema = null) { - return $this->schemaManager->getCollections(true); + unset($schema); + + return $this->mapResultsToArray( + $this->schemaManager->getCollections(true), + ); } /** @@ -128,10 +172,10 @@ public function drop($table) */ public function dropAllTables(): void { - $collections = $this->getAllTables(); + $collections = $this->getTables(true); foreach ($collections as $name) { - $this->schemaManager->deleteCollection($name->name); + $this->schemaManager->deleteCollection($name['name']); } } @@ -147,6 +191,22 @@ public function hasColumn($table, $column) return $this->hasColumns($table, $column); } + /** + * Determine if the given table has given columns. + * + * @param string $table + * @param string|string[] $columns + * @return array + */ + public function getColumns($table) + { + $compilation = $this->grammar->compileColumns(null, $table); + + $rawColumns = $this->connection->select($compilation['aqb'], $compilation['bindings']); + + return $this->mapResultsToArray($rawColumns); + } + /** * Determine if the given table has given columns. * @@ -237,6 +297,15 @@ public function withoutForeignKeyConstraints(Closure $callback) return $callback(); } + /** + * @param mixed[] $results + * @return mixed[] + */ + protected function mapResultsToArray($results) + { + return array_map(function ($result) { return (array) $result; }, $results); + } + /** * Silently catch the use of unsupported builder methods. */ diff --git a/src/Schema/Concerns/Columns.php b/src/Schema/Concerns/ColumnCommands.php similarity index 98% rename from src/Schema/Concerns/Columns.php rename to src/Schema/Concerns/ColumnCommands.php index 0350d1c..f49b500 100644 --- a/src/Schema/Concerns/Columns.php +++ b/src/Schema/Concerns/ColumnCommands.php @@ -6,7 +6,7 @@ use Illuminate\Support\Fluent; -trait Columns +trait ColumnCommands { /** * Indicate that the given attributes should be renamed. diff --git a/src/Schema/Concerns/HandlesAnalyzers.php b/src/Schema/Concerns/HandlesAnalyzers.php index 99a5c31..3da7393 100644 --- a/src/Schema/Concerns/HandlesAnalyzers.php +++ b/src/Schema/Concerns/HandlesAnalyzers.php @@ -10,33 +10,48 @@ trait HandlesAnalyzers { /** * @param array $properties + * @param array $features * * @throws ArangoException */ - public function createAnalyzer(string $name, array $properties) + public function createAnalyzer(string $name, string $type, array $properties = null, array $features = null) { - $analyzer = $properties; - $analyzer['name'] = $name; + $analyzer = array_filter([ + 'name' => $name, + 'type' => $type, + 'properties' => $properties, + 'features' => $features, + ]); $this->schemaManager->createAnalyzer($analyzer); } /** * @param array $properties + * @param array $features * * @throws ArangoException */ - public function replaceAnalyzer(string $name, array $properties) + public function replaceAnalyzer(string $name, string $type, array $properties = null, array $features = null) { - $this->schemaManager->replaceAnalyzer($name, $properties); + $analyzer = array_filter([ + 'name' => $name, + 'type' => $type, + 'properties' => $properties, + 'features' => $features, + ]); + + $this->schemaManager->replaceAnalyzer($name, $analyzer); } /** + * @param string $name + * @return mixed[] * @throws ArangoException */ - public function getAnalyzer(string $name): \stdClass + public function getAnalyzer(string $name): array { - return $this->schemaManager->getAnalyzer($name); + return (array) $this->schemaManager->getAnalyzer($name); } public function hasAnalyzer(string $analyzer): bool @@ -49,9 +64,11 @@ public function hasAnalyzer(string $analyzer): bool /** * @throws ArangoException */ - public function getAllAnalyzers(): array + public function getAnalyzers(): array { - return $this->schemaManager->getAnalyzers(); + return $this->mapResultsToArray( + $this->schemaManager->getAnalyzers(), + ); } /** @@ -73,4 +90,14 @@ public function dropAnalyzerIfExists(string $name): bool return true; } + + /** + * Drop all custom analyzers from the schema. + * + * @throws ArangoException + */ + public function dropAllAnalyzers(): void + { + $this->schemaManager->deleteAllAnalyzers(); + } } diff --git a/src/Schema/Concerns/HandlesGraphs.php b/src/Schema/Concerns/HandlesGraphs.php new file mode 100644 index 0000000..7557155 --- /dev/null +++ b/src/Schema/Concerns/HandlesGraphs.php @@ -0,0 +1,74 @@ + $properties + * @throws ArangoException + * + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") + */ + public function createGraph(string $name, array $properties = [], bool $waitForSync = false) + { + $this->schemaManager->createGraph($name, $properties, $waitForSync); + } + + public function hasGraph(string $name): bool + { + return $this->handleExceptionsAsQueryExceptions(function () use ($name) { + return $this->schemaManager->hasGraph($name); + }); + } + + /** + * @param string $name + * @return mixed[] + * @throws ArangoException + */ + public function getGraph(string $name): array + { + return (array) $this->schemaManager->getGraph($name); + } + + /** + * @return mixed[] + * @throws ArangoException + */ + public function getGraphs(): array + { + return $this->mapResultsToArray( + $this->schemaManager->getGraphs(), + ); + } + + /** + * @throws ArangoException + */ + public function dropGraph(string $name) + { + $this->schemaManager->deleteGraph($name); + } + + /** + * @throws ArangoException + */ + public function dropGraphIfExists(string $name): bool + { + if ($this->hasGraph($name)) { + $this->schemaManager->deleteGraph($name); + } + + return true; + } + + public function dropAllGraphs(): void + { + $this->schemaManager->deleteAllGraphs(); + } +} diff --git a/src/Schema/Concerns/HandlesIndexNaming.php b/src/Schema/Concerns/HandlesIndexNaming.php new file mode 100644 index 0000000..293ef47 --- /dev/null +++ b/src/Schema/Concerns/HandlesIndexNaming.php @@ -0,0 +1,43 @@ +prefix . ($table ?? $this->table); + $nameParts = array_merge($nameParts, $this->getColumnNames($columns)); + $nameParts[] = $type; + $nameParts = array_merge($nameParts, array_keys($options)); + array_filter($nameParts); + + $index = strtolower(implode('_', $nameParts)); + $index = preg_replace("/\[\*+\]+/", '_array', $index); + + return preg_replace('/[^A-Za-z0-9]+/', '_', $index); + } + + protected function getColumnNames(array $columns): array + { + $names = []; + foreach ($columns as $column) { + if (is_array($column) && $column['name'] !== '') { + $names[] = $column['name']; + + continue; + } + $names[] = $column; + } + + return $names; + } +} diff --git a/src/Schema/Concerns/HandlesIndexes.php b/src/Schema/Concerns/HandlesIndexes.php new file mode 100644 index 0000000..fb44315 --- /dev/null +++ b/src/Schema/Concerns/HandlesIndexes.php @@ -0,0 +1,54 @@ + $index + * @param string|null $type + * @return bool + */ + public function hasIndex($table, $index, $type = null, array $options = []) + { + $name = $index; + + if ($type === null) { + $type = 'persistent'; + } + + if (is_array($index)) { + $name = $this->createIndexName($type, $index, $options, $table); + } + + return !!$this->schemaManager->getIndexByName($table, $name); + } + + /** + * @param string $id + * @return array + */ + public function getIndex(string $id) + { + return (array) $this->schemaManager->getIndex($id); + } + + /** + * @param string $table + * @return mixed[] + * @throws ArangoException + */ + public function getIndexes($table) + { + return $this->mapResultsToArray( + $this->schemaManager->getIndexes($table), + ); + } +} diff --git a/src/Schema/Concerns/HandlesViews.php b/src/Schema/Concerns/HandlesViews.php index b3c67aa..a775a25 100644 --- a/src/Schema/Concerns/HandlesViews.php +++ b/src/Schema/Concerns/HandlesViews.php @@ -23,11 +23,13 @@ public function createView(string $name, array $properties, string $type = 'aran } /** + * @param string $name + * @return mixed[] * @throws ArangoException */ - public function getView(string $name): \stdClass + public function getView(string $name): array { - return $this->schemaManager->getView($name); + return (array) $this->schemaManager->getView($name); } public function hasView($view) @@ -38,11 +40,17 @@ public function hasView($view) } /** + * @param string|string[]|null $schema + * @return mixed[] * @throws ArangoException */ - public function getAllViews(): array + public function getViews($schema = null): array { - return $this->schemaManager->getViews(); + unset($schema); + + return $this->mapResultsToArray( + $this->schemaManager->getViews(), + ); } /** @@ -85,13 +93,9 @@ public function dropViewIfExists(string $name): bool * Drop all views from the schema. * * @throws ArangoException - */ + */ public function dropAllViews(): void { - $views = $this->schemaManager->getViews(); - - foreach ($views as $view) { - $this->schemaManager->deleteView($view->name); - } + $this->schemaManager->deleteAllViews(); } } diff --git a/src/Schema/Concerns/Indexes.php b/src/Schema/Concerns/IndexCommands.php similarity index 92% rename from src/Schema/Concerns/Indexes.php rename to src/Schema/Concerns/IndexCommands.php index 2fe4415..ab87a9b 100644 --- a/src/Schema/Concerns/Indexes.php +++ b/src/Schema/Concerns/IndexCommands.php @@ -5,8 +5,10 @@ use ArangoClient\Exceptions\ArangoException; use Illuminate\Support\Fluent; -trait Indexes +trait IndexCommands { + use HandlesIndexNaming; + /** * Add a new index command to the blueprint. * @@ -88,6 +90,17 @@ public function invertedIndex($columns = null, $name = null, $indexOptions = []) return $this->indexCommand('inverted', $columns, $name, $indexOptions); } + + public function multiDimensionalIndex(array $columns = null, string $name = null, array $indexOptions = [], string $type = 'mdi'): Fluent + { + return $this->indexCommand( + $type, + $columns, + $name, + $indexOptions, + ); + } + public function persistentIndex(array $columns = null, string $name = null, array $indexOptions = []): Fluent { return $this->indexCommand('persistent', $columns, $name, $indexOptions); @@ -226,7 +239,13 @@ public function dropInvertedIndex(string $name): Fluent return $this->dropIndex($name); } - + /** + * Indicate that the given index should be dropped. + */ + public function dropMultiDimensionalIndex(string $name): Fluent + { + return $this->dropIndex($name); + } /** * Drop the index by first getting all the indexes on the table; then selecting the matching one @@ -264,23 +283,4 @@ protected function mapIndexAlgorithm($algorithm): mixed return (isset($algorithmConversion[$algorithm])) ? $algorithmConversion[$algorithm] : 'persistent'; } - /** - * Create a default index name for the table. - * - * @param string $type - */ - public function createIndexName($type, array $columns, array $options = []): string - { - $nameParts = []; - $nameParts[] = $this->prefix . $this->table; - $nameParts = array_merge($nameParts, $columns); - $nameParts[] = $type; - $nameParts = array_merge($nameParts, array_keys($options)); - array_filter($nameParts); - - $index = strtolower(implode('_', $nameParts)); - $index = preg_replace("/\[\*+\]+/", '_array', $index); - - return preg_replace('/[^A-Za-z0-9]+/', '_', $index); - } } diff --git a/src/Schema/Concerns/Tables.php b/src/Schema/Concerns/TableCommands.php similarity index 50% rename from src/Schema/Concerns/Tables.php rename to src/Schema/Concerns/TableCommands.php index dfecfd0..8212efd 100644 --- a/src/Schema/Concerns/Tables.php +++ b/src/Schema/Concerns/TableCommands.php @@ -6,7 +6,7 @@ use Illuminate\Support\Fluent; -trait Tables +trait TableCommands { /** * Indicate that the table needs to be created. @@ -17,35 +17,55 @@ trait Tables public function create($options = []) { $parameters = []; - $parameters['options'] = array_merge( - [ - 'keyOptions' => config('arangodb.schema.keyOptions'), - ], - $options, - ); + $parameters['options'] = $options; $parameters['explanation'] = "Create '{$this->table}' table."; $parameters['handler'] = 'table'; return $this->addCommand('create', $parameters); } - public function executeCreateCommand($command) + /** + * @param string $command + * @param mixed[] $args + * @return void + */ + public function handleKeyCommands($command, $args) { - if ($this->connection->pretending()) { - $this->connection->logQuery('/* ' . $command->explanation . " */\n", []); + $acceptedKeyFields = ['id', '_id', '_key']; + + $columns = ($command === 'autoIncrement') ? end($this->columns) : $args; + $columns = (is_array($columns)) ? $columns : [$columns]; + if (count($columns) !== 1 || ! in_array($columns[0], $acceptedKeyFields)) { return; } - $options = $command->options; - if ($this->keyGenerator !== 'traditional') { - $options['keyOptions']['type'] = $this->keyGenerator; + if ($command === 'uuid') { + $this->keyGenerator = 'uuid'; + + return; } - if ($this->keyGenerator === 'autoincrement' && $this->incrementOffset !== 0) { - $options['keyOptions']['offset'] = $this->incrementOffset; + if (config('arangodb.schema.key_handling.use_traditional_over_autoincrement') === false) { + $this->keyGenerator = 'autoincrement'; + + return; } + $this->keyGenerator = 'traditional'; + } + + public function executeCreateCommand($command) + { + if ($this->connection->pretending()) { + $this->connection->logQuery('/* ' . $command->explanation . " */\n", []); + + return; + } + + $options = $command->options; + $options['keyOptions'] = $this->setKeyOptions($options['keyOptions'] ?? []); + if (!$this->schemaManager->hasCollection($this->table)) { $this->schemaManager->createCollection($this->table, $options); } diff --git a/src/Schema/Grammar.php b/src/Schema/Grammar.php index 9f31fb1..7bbf97a 100644 --- a/src/Schema/Grammar.php +++ b/src/Schema/Grammar.php @@ -6,6 +6,7 @@ use Illuminate\Database\Schema\Grammars\Grammar as IlluminateGrammar; use Illuminate\Support\Fluent; +use LaravelFreelancerNL\FluentAQL\Exceptions\BindException; use LaravelFreelancerNL\FluentAQL\QueryBuilder; class Grammar extends IlluminateGrammar @@ -19,6 +20,55 @@ class Grammar extends IlluminateGrammar */ protected $transactions = false; + /** + * Compile AQL to check if an attribute is in use within a document in the collection. + * If multiple attributes are set then all must be set in one document. + * + * @param string|null $schema + * @param string $table + * @return Fluent + * @throws BindException + */ + public function compileColumns($schema, $table) + { + // At this time we don't use the schema; even if it has been set on the table. + unset($schema); + + $parameters = []; + $parameters['name'] = 'columns'; + $parameters['handler'] = 'aql'; + $parameters['table'] = $table; + + $command = new Fluent($parameters); + + + $command->bindings = [ + '@collection' => $table, + ]; + + $command->aqb = sprintf( + 'LET rawColumns = MERGE_RECURSIVE( + ( + FOR doc IN @@collection + LET fields = ATTRIBUTES(doc, true, true) + FOR field IN fields + RETURN { + [field]: { + [TYPENAME(doc[field])]: true + } + } + ) + ) + FOR column IN ATTRIBUTES(rawColumns) + RETURN { + name: column, + type: ATTRIBUTES(rawColumns[column]) + }', + $table, + ); + + return $command; + } /** * Compile AQL to check if an attribute is in use within a document in the collection. * If multiple attributes are set then all must be set in one document. diff --git a/src/Testing/Concerns/CanConfigureMigrationCommands.php b/src/Testing/Concerns/CanConfigureMigrationCommands.php new file mode 100644 index 0000000..40d46b4 --- /dev/null +++ b/src/Testing/Concerns/CanConfigureMigrationCommands.php @@ -0,0 +1,59 @@ +|string> + */ + protected function setMigrationPaths() + { + $migrationSettings = []; + + if (property_exists($this, 'realPath')) { + $migrationSettings['--realpath'] = $this->realPath ?? false; + } + + if (property_exists($this, 'migrationPaths')) { + $migrationSettings['--path'] = $this->migrationPaths; + } + + return $migrationSettings; + } + + /** + * Determine if custom analyzers should be dropped when refreshing the database. + * + * @return bool + */ + protected function shouldDropAnalyzers() + { + return property_exists($this, 'dropAnalyzers') ? $this->dropAnalyzers : false; + } + + /** + * Determine if graphs should be dropped when refreshing the database. + * + * @return bool + */ + protected function shouldDropGraphs() + { + return property_exists($this, 'dropGraphs') ? $this->dropGraphs : false; + } + + + /** + * Determine if all analyzers, graphs and views should be dropped when refreshing the database. + * + * @return bool + */ + protected function shouldDropAll() + { + return property_exists($this, 'dropAll') ? $this->dropAll : false; + } +} diff --git a/src/Testing/Concerns/InteractsWithDatabase.php b/src/Testing/Concerns/InteractsWithDatabase.php index f73e651..a0f9195 100644 --- a/src/Testing/Concerns/InteractsWithDatabase.php +++ b/src/Testing/Concerns/InteractsWithDatabase.php @@ -5,133 +5,22 @@ namespace LaravelFreelancerNL\Aranguent\Testing\Concerns; use Illuminate\Support\Facades\DB; -use Illuminate\Testing\Constraints\HasInDatabase; -use Illuminate\Testing\Constraints\NotSoftDeletedInDatabase; -use Illuminate\Testing\Constraints\SoftDeletedInDatabase; -use PHPUnit\Framework\Constraint\LogicalNot as ReverseConstraint; trait InteractsWithDatabase { - /** - * Assert that a given where condition exists in the database. - * - * @param \Illuminate\Database\Eloquent\Model|string $table - * @param array $data - * @param string|null $connection - * @return $this - */ - protected function assertDatabaseHas($table, array $data, $connection = null) - { - $this->assertThat( - $this->getTable($table), - new HasInDatabase($this->getConnection($connection), associativeFlatten($data)), - ); - - return $this; - } - - /** - * Assert that a given where condition does not exist in the database. - * - * @param \Illuminate\Database\Eloquent\Model|string $table - * @param array $data - * @param string|null $connection - * @return $this - */ - protected function assertDatabaseMissing($table, array $data, $connection = null) - { - $constraint = new ReverseConstraint( - new HasInDatabase($this->getConnection($connection), associativeFlatten($data)), - ); - - $this->assertThat($this->getTable($table), $constraint); - - return $this; - } - - /** - * Assert the given record has been "soft deleted". - * - * @param \Illuminate\Database\Eloquent\Model|string $table - * @param array $data - * @param string|null $connection - * @param string|null $deletedAtColumn - * @return $this - */ - protected function assertSoftDeleted( - $table, - array $data = [], - $connection = null, - $deletedAtColumn = 'deleted_at', - ) { - if ($this->isSoftDeletableModel($table) && !is_string($table)) { - return $this->assertSoftDeleted( - $table->getTable(), - [$table->getKeyName() => $table->getKey()], - $table->getConnectionName(), /** @phpstan-ignore-next-line */ - $table->getDeletedAtColumn(), - ); - } - - $this->assertThat( - $this->getTable($table), - new SoftDeletedInDatabase( - $this->getConnection($connection), - associativeFlatten($data), - (string) $deletedAtColumn, - ), - ); - - return $this; - } - - /** - * Assert the given record has not been "soft deleted". - * - * @param \Illuminate\Database\Eloquent\Model|string $table - * @param array $data - * @param string|null $connection - * @param string|null $deletedAtColumn - * @return $this - */ - protected function assertNotSoftDeleted( - $table, - array $data = [], - $connection = null, - $deletedAtColumn = 'deleted_at', - ) { - if ($this->isSoftDeletableModel($table) && !is_string($table)) { - return $this->assertNotSoftDeleted( - $table->getTable(), - [$table->getKeyName() => $table->getKey()], - $table->getConnectionName(), /** @phpstan-ignore-next-line */ - $table->getDeletedAtColumn(), - ); - } - - $this->assertThat( - $this->getTable($table), - new NotSoftDeletedInDatabase( - $this->getConnection($connection), - associativeFlatten($data), - (string) $deletedAtColumn, - ), - ); - - return $this; - } - /** * Cast a JSON string to a database compatible type. * Supported for backwards compatibility in existing projects. * No cast is necessary as json is a first class citizen in ArangoDB. * * @param array|object|string $value + * @param string|null $connection * @return \Illuminate\Contracts\Database\Query\Expression + * + * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ - public function castAsJson($value) + public function castAsJson($value, $connection = null) { return DB::raw($value); } - } diff --git a/src/Testing/DatabaseMigrations.php b/src/Testing/DatabaseMigrations.php index ea3363b..d5cf3f3 100644 --- a/src/Testing/DatabaseMigrations.php +++ b/src/Testing/DatabaseMigrations.php @@ -5,14 +5,19 @@ namespace LaravelFreelancerNL\Aranguent\Testing; use Illuminate\Foundation\Testing\DatabaseMigrations as IlluminateDatabaseMigrations; +use LaravelFreelancerNL\Aranguent\Testing\Concerns\CanConfigureMigrationCommands; trait DatabaseMigrations { use IlluminateDatabaseMigrations; + use CanConfigureMigrationCommands; + /** * The parameters that should be used when running "migrate:fresh". * + * Duplicate code because CanConfigureMigrationCommands has a conflict otherwise. + * * @return array */ protected function migrateFreshUsing() @@ -21,8 +26,11 @@ protected function migrateFreshUsing() $results = array_merge( [ + '--drop-analyzers' => $this->shouldDropAnalyzers(), + '--drop-graphs' => $this->shouldDropGraphs(), '--drop-views' => $this->shouldDropViews(), '--drop-types' => $this->shouldDropTypes(), + '--drop-all' => $this->shouldDropAll(), ], $seeder ? ['--seeder' => $seeder] : ['--seed' => $this->shouldSeed()], $this->setMigrationPaths(), @@ -30,24 +38,4 @@ protected function migrateFreshUsing() return $results; } - - /** - * Determine if types should be dropped when refreshing the database. - * - * @return array|string> - */ - protected function setMigrationPaths() - { - $migrationSettings = []; - - if(property_exists($this, 'realPath')) { - $migrationSettings['--realpath'] = $this->realPath ?? false; - } - - if (property_exists($this, 'migrationPaths')) { - $migrationSettings['--path'] = $this->migrationPaths; - } - - return $migrationSettings; - } } diff --git a/src/Testing/DatabaseTransactions.php b/src/Testing/DatabaseTransactions.php index 13933d6..c959bf1 100644 --- a/src/Testing/DatabaseTransactions.php +++ b/src/Testing/DatabaseTransactions.php @@ -22,7 +22,9 @@ public function beginDatabaseTransaction() { $database = $this->app->make('db'); - $this->app->instance('db.transactions', $transactionsManager = new DatabaseTransactionsManager()); + $connections = $this->connectionsToTransact(); + + $this->app->instance('db.transactions', $transactionsManager = new DatabaseTransactionsManager($connections)); foreach ($this->connectionsToTransact() as $name) { $connection = $database->connection($name); diff --git a/src/Testing/DatabaseTruncation.php b/src/Testing/DatabaseTruncation.php index 04bb172..272de56 100644 --- a/src/Testing/DatabaseTruncation.php +++ b/src/Testing/DatabaseTruncation.php @@ -4,15 +4,21 @@ namespace LaravelFreelancerNL\Aranguent\Testing; +use Illuminate\Database\ConnectionInterface; use Illuminate\Foundation\Testing\DatabaseTruncation as IlluminateDatabaseTruncation; +use Illuminate\Support\Collection; +use LaravelFreelancerNL\Aranguent\Testing\Concerns\CanConfigureMigrationCommands; trait DatabaseTruncation { use IlluminateDatabaseTruncation; + use CanConfigureMigrationCommands; /** * The parameters that should be used when running "migrate:fresh". * + * Duplicate code because CanConfigureMigrationCommands has a conflict otherwise. + * * @return array */ protected function migrateFreshUsing() @@ -21,8 +27,11 @@ protected function migrateFreshUsing() $results = array_merge( [ + '--drop-analyzers' => $this->shouldDropAnalyzers(), + '--drop-graphs' => $this->shouldDropGraphs(), '--drop-views' => $this->shouldDropViews(), '--drop-types' => $this->shouldDropTypes(), + '--drop-all' => $this->shouldDropAll(), ], $seeder ? ['--seeder' => $seeder] : ['--seed' => $this->shouldSeed()], $this->setMigrationPaths(), @@ -32,22 +41,51 @@ protected function migrateFreshUsing() } /** - * Determine if types should be dropped when refreshing the database. + * Determine if a table exists in the given list, with or without its schema. + */ + protected function tableExistsIn(array $table, array $tables): bool + { + return isset($table['schema']) + ? ! empty(array_intersect([$table['name'], $table['schema'] . '.' . $table['name']], $tables)) + : in_array($table['name'], $tables); + } + + /** + * Truncate the database tables for the given database connection. * - * @return array|string> + * @param \Illuminate\Database\ConnectionInterface $connection + * @param string|null $name + * @return void */ - protected function setMigrationPaths() + protected function truncateTablesForConnection(ConnectionInterface $connection, ?string $name): void { - $migrationSettings = []; + $dispatcher = $connection->getEventDispatcher(); - if(property_exists($this, 'realPath')) { - $migrationSettings['--realpath'] = $this->realPath ?? false; - } + $connection->unsetEventDispatcher(); - if (property_exists($this, 'migrationPaths')) { - $migrationSettings['--path'] = $this->migrationPaths; - } + (new Collection($this->getAllTablesForConnection($connection, $name))) + ->when( + $this->tablesToTruncate($connection, $name), + function (Collection $tables, array $tablesToTruncate) { + return $tables->filter(fn(array $table) => $this->tableExistsIn($table, $tablesToTruncate)); + }, + function (Collection $tables) use ($connection, $name) { + $exceptTables = $this->exceptTables($connection, $name); + return $tables->filter(fn(array $table) => ! $this->tableExistsIn($table, $exceptTables)); + }, + ) + ->each(function (array $table) use ($connection) { + $connection->withoutTablePrefix(function ($connection) use ($table) { + $table = $connection->table( + isset($table['schema']) ? $table['schema'] . '.' . $table['name'] : $table['name'], + ); + if ($table->exists()) { + $table->truncate(); + } + }); + }); - return $migrationSettings; + $connection->setEventDispatcher($dispatcher); } + } diff --git a/src/Testing/RefreshDatabase.php b/src/Testing/RefreshDatabase.php index 1cfece4..5d6e3fd 100644 --- a/src/Testing/RefreshDatabase.php +++ b/src/Testing/RefreshDatabase.php @@ -6,11 +6,13 @@ use Illuminate\Foundation\Testing\DatabaseTransactionsManager; use Illuminate\Foundation\Testing\RefreshDatabase as IlluminateRefreshDatabase; +use LaravelFreelancerNL\Aranguent\Testing\Concerns\CanConfigureMigrationCommands; use LaravelFreelancerNL\Aranguent\Testing\Concerns\PreparesTestingTransactions; trait RefreshDatabase { use PreparesTestingTransactions; + use CanConfigureMigrationCommands; use IlluminateRefreshDatabase; /** @@ -22,7 +24,9 @@ public function beginDatabaseTransaction() { $database = $this->app->make('db'); - $this->app->instance('db.transactions', $transactionsManager = new DatabaseTransactionsManager()); + $connections = $this->connectionsToTransact(); + + $this->app->instance('db.transactions', $transactionsManager = new DatabaseTransactionsManager($connections)); foreach ($this->connectionsToTransact() as $name) { $connection = $database->connection($name); @@ -49,9 +53,12 @@ public function beginDatabaseTransaction() }); } + /** * The parameters that should be used when running "migrate:fresh". * + * Duplicate code because CanConfigureMigrationCommands has a conflict otherwise. + * * @return array */ protected function migrateFreshUsing() @@ -60,8 +67,11 @@ protected function migrateFreshUsing() $results = array_merge( [ + '--drop-analyzers' => $this->shouldDropAnalyzers(), + '--drop-graphs' => $this->shouldDropGraphs(), '--drop-views' => $this->shouldDropViews(), '--drop-types' => $this->shouldDropTypes(), + '--drop-all' => $this->shouldDropAll(), ], $seeder ? ['--seeder' => $seeder] : ['--seed' => $this->shouldSeed()], $this->setMigrationPaths(), @@ -79,7 +89,7 @@ protected function setMigrationPaths() { $migrationSettings = []; - if(property_exists($this, 'realPath')) { + if (property_exists($this, 'realPath')) { $migrationSettings['--realpath'] = $this->realPath ?? false; } diff --git a/src/helpers.php b/src/helpers.php index 8a8809f..0b0085e 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -46,6 +46,17 @@ function isDotString(string $string): bool } } +if (!function_exists('mapObjectToArray')) { + function mapObjectToArray(mixed $value): mixed + { + if (!is_object($value) && !is_array($value)) { + return $value; + } + + return array_map('mapObjectToArray', (array) $value); + } +} + if (!function_exists('renameArrayKey')) { /** * @param array $array diff --git a/tests/ArchTest.php b/tests/ArchTest.php index b3b8763..2d41668 100644 --- a/tests/ArchTest.php +++ b/tests/ArchTest.php @@ -1,6 +1,10 @@ expect(['dd', 'dump', 'ray']) -// ->each->not->toBeUsed(); +declare (strict_types=1); + +/* + * Besides ray we don't test for debug function use as we need to test their proper functioning in the package. + */ +it('will not use debugging functions') + ->expect(['ray']) + ->each->not->toBeUsed(); diff --git a/tests/Console/DbShowCommandTest.php b/tests/Console/DbShowCommandTest.php new file mode 100644 index 0000000..d06cd57 --- /dev/null +++ b/tests/Console/DbShowCommandTest.php @@ -0,0 +1,95 @@ +artisan('db:show') + ->expectsOutputToContain('Connection') + ->expectsOutputToContain('Database') + ->expectsOutputToContain('Analyzers') + ->expectsOutputToContain('Views') + ->expectsOutputToContain('Named Graphs') + ->expectsOutputToContain('characters') + ->expectsOutputToContain('users') + ->assertSuccessful(); +}); + +test('db:show --counts', function () { + $this->artisan( + 'db:show', + [ + '--counts' => true, + ], + ) + ->expectsOutputToContain('Size Estimate / Rows') + ->expectsOutputToContain('/ 43') + ->assertSuccessful(); +}); + + +test('db:show --analyzers', function () { + $this->artisan( + 'db:show', + [ + '--analyzers' => true, + ], + ) + ->expectsOutputToContain('Analyzers') + ->expectsOutputToContain('text_nl') + ->expectsOutputToContain('identity') + ->assertSuccessful(); +}); + +test('db:show --views', function () { + $this->artisan( + 'db:show', + [ + '--views' => true, + ], + ) + ->expectsOutputToContain('View') + ->expectsOutputToContain('house_search_alias_view') + ->expectsOutputToContain('arangosearch') + ->assertSuccessful(); +}); + +test('db:show --graphs', function () { + $this->schemaManager = $this->connection->getArangoClient()->schema(); + + $this->schemaManager->createGraph( + 'relatives', + [ + 'edgeDefinitions' => [ + [ + 'collection' => 'children', + 'from' => ['characters'], + 'to' => ['characters'], + ], + ], + ], + ); + + $this->artisan( + 'db:show', + [ + '--graphs' => true, + ], + ) + ->expectsOutputToContain('Graphs') + ->expectsOutputToContain('relatives') + ->assertSuccessful(); + + + $this->schemaManager->deleteGraph('relatives'); +}); + +test('db:show --system', function () { + $this->artisan( + 'db:show', + [ + '--system' => true, + ], + ) + ->expectsOutputToContain('_analyzers') + ->expectsOutputToContain('_jobs') + ->expectsOutputToContain('_queues') + ->assertSuccessful(); +}); diff --git a/tests/Console/DbTableCommandTest.php b/tests/Console/DbTableCommandTest.php new file mode 100644 index 0000000..79617c3 --- /dev/null +++ b/tests/Console/DbTableCommandTest.php @@ -0,0 +1,61 @@ +artisan('db:table') + ->expectsQuestion('Which table would you like to inspect?', 'children') + ->expectsOutputToContain('children') + ->expectsOutputToContain('Edge') + ->expectsOutputToContain('User Keys Allowed') + ->expectsOutputToContain('Key Type') + ->expectsOutputToContain('Last Used Key') + ->expectsOutputToContain('Wait For Sync') + ->expectsOutputToContain('Columns') + ->expectsOutputToContain('Size Estimate') + ->expectsOutputToContain('primary _key') + ->expectsOutputToContain('edge') + ->assertSuccessful(); +}); + +test('db:table children', function () { + $this->artisan('db:table', ['table' => 'children']) + ->expectsOutputToContain('children') + ->expectsOutputToContain('Edge') + ->expectsOutputToContain('User Keys Allowed') + ->expectsOutputToContain('Key Type') + ->expectsOutputToContain('Last Used Key') + ->expectsOutputToContain('Wait For Sync') + ->expectsOutputToContain('Columns') + ->expectsOutputToContain('Size Estimate') + ->expectsOutputToContain('primary _key') + ->expectsOutputToContain('edge') + ->assertSuccessful(); +}); + +test('db:table characters', function () { + $this->artisan('db:table', ['table' => 'characters']) + ->expectsOutputToContain('characters') + ->expectsOutputToContain('Vertex') + ->expectsOutputToContain('User Keys Allowed') + ->expectsOutputToContain('Key Type') + ->expectsOutputToContain('Last Used Key') + ->expectsOutputToContain('Wait For Sync') + ->expectsOutputToContain('Columns') + ->expectsOutputToContain('Size Estimate') + ->expectsOutputToContain('primary _key') + ->expectsOutputToContain('full_name') + ->assertSuccessful(); +}); + +test('db:table _job', function () { + $this->artisan('db:table', ['table' => '_jobs']) + ->expectsOutputToContain('_jobs') + ->expectsOutputToContain('Vertex') + ->expectsOutputToContain('User Keys Allowed') + ->expectsOutputToContain('Key Type') + ->expectsOutputToContain('Last Used Key') + ->expectsOutputToContain('Wait For Sync') + ->expectsOutputToContain('Columns') + ->expectsOutputToContain('Size Estimate') + ->expectsOutputToContain('primary _key') + ->assertSuccessful(); +}); diff --git a/tests/Console/MigrateFreshCommandTest.php b/tests/Console/MigrateFreshCommandTest.php index 52aa689..0e71f5a 100644 --- a/tests/Console/MigrateFreshCommandTest.php +++ b/tests/Console/MigrateFreshCommandTest.php @@ -1,5 +1,7 @@ assertExitCode(0); $collections = $this->schemaManager->getCollections(true); - expect(count($collections))->toBe(15); + expect(count($collections))->toBe($this->tableCount); }); test('migrate:fresh --database=arangodb', function () { @@ -46,7 +48,7 @@ ])->assertExitCode(0); $collections = $this->schemaManager->getCollections(true); - expect(count($collections))->toBe(15); + expect(count($collections))->toBe($this->tableCount); }); test('migrate:fresh --database=none', function () { @@ -54,3 +56,180 @@ '--database' => 'none', ])->assertExitCode(0); })->throws(InvalidArgumentException::class); + +test('migrate:fresh --drop-views', function () { + $path = [ + realpath(__DIR__ . '/../../TestSetup/Database/Migrations'), + ]; + + if (!$this->schemaManager->hasView('dropViewTest')) { + Schema::createView('dropViewTest', []); + } + + $this->artisan('migrate:fresh', [ + '--path' => [ + database_path('migrations'), + realpath(__DIR__ . '/../../TestSetup/Database/Migrations'), + realpath(__DIR__ . '/../../vendor/orchestra/testbench-core/laravel/migrations/'), + ], + '--realpath' => true, + '--seed' => true, + '--seeder' => DatabaseSeeder::class, + '--drop-views' => true, + + ])->assertExitCode(0); + + $views = $this->schemaManager->getViews(); + expect(count($views))->toBe(2); +}); + +test('migrate:fresh --drop-analyzers', function () { + $initialAnalyzers = $this->schemaManager->getAnalyzers(); + + $path = [ + realpath(__DIR__ . '/../../TestSetup/Database/Migrations'), + ]; + + if (!$this->schemaManager->hasAnalyzer('dropMyAnalyzer')) { + Schema::createAnalyzer( + 'dropMyAnalyzer', + 'identity', + ); + } + + + $analyzersAfterCreation = $this->schemaManager->getAnalyzers(); + + $this->artisan('migrate:fresh', [ + '--path' => [ + database_path('migrations'), + realpath(__DIR__ . '/../../TestSetup/Database/Migrations'), + realpath(__DIR__ . '/../../vendor/orchestra/testbench-core/laravel/migrations/'), + ], + '--realpath' => true, + '--seed' => true, + '--seeder' => DatabaseSeeder::class, + '--drop-analyzers' => true, + + ])->assertExitCode(0); + + $endAnalyzers = $this->schemaManager->getAnalyzers(); + + expect(count($analyzersAfterCreation))->toBe(1 + count($initialAnalyzers)); + expect(count($initialAnalyzers))->toBe(count($endAnalyzers)); +}); + +test('migrate:fresh --drop-graphs', function () { + $path = [ + realpath(__DIR__ . '/../../TestSetup/Database/Migrations'), + ]; + + if (!$this->schemaManager->hasGraph('dropMyGraph')) { + Schema::createGraph( + 'dropMyGraph', + [ + 'edgeDefinitions' => [ + [ + 'collection' => 'children', + 'from' => ['characters'], + 'to' => ['characters'], + ], + ], + ], + true, + ); + } + + $this->artisan('migrate:fresh', [ + '--path' => [ + database_path('migrations'), + realpath(__DIR__ . '/../../TestSetup/Database/Migrations'), + realpath(__DIR__ . '/../../vendor/orchestra/testbench-core/laravel/migrations/'), + ], + '--realpath' => true, + '--seed' => true, + '--seeder' => DatabaseSeeder::class, + '--drop-graphs' => true, + + ])->assertExitCode(0); + + $graphs = $this->schemaManager->getGraphs(); + expect(count($graphs))->toBe(0); +}); + +test('migrate:fresh --drop-all', function () { + $initialViews = $this->schemaManager->getViews(); + $initialAnalyzers = $this->schemaManager->getAnalyzers(); + $initialGraphs = $this->schemaManager->getGraphs(); + + $path = [ + realpath(__DIR__ . '/../../TestSetup/Database/Migrations'), + ]; + + if (!$this->schemaManager->hasAnalyzer('dropMyAnalyzer')) { + Schema::createAnalyzer( + 'dropMyAnalyzer', + 'identity', + ); + } + if (!$this->schemaManager->hasGraph('dropMyGraph')) { + Schema::createGraph( + 'dropMyGraph', + [ + 'edgeDefinitions' => [ + [ + 'collection' => 'children', + 'from' => ['characters'], + 'to' => ['characters'], + ], + ], + ], + true, + ); + } + + if (!$this->schemaManager->hasView('dropViewTest')) { + Schema::createView('dropViewTest', []); + } + + $this->artisan('migrate:fresh', [ + '--path' => [ + database_path('migrations'), + realpath(__DIR__ . '/../../TestSetup/Database/Migrations'), + realpath(__DIR__ . '/../../vendor/orchestra/testbench-core/laravel/migrations/'), + ], + '--realpath' => true, + '--seed' => true, + '--seeder' => DatabaseSeeder::class, + '--drop-all' => true, + + ])->assertExitCode(0); + + $endAnalyzers = $this->schemaManager->getAnalyzers(); + $endGraphs = $this->schemaManager->getGraphs(); + $endViews = $this->schemaManager->getViews(); + + expect(count($initialAnalyzers))->toBe(count($endAnalyzers)); + expect(count($initialGraphs))->toBe(count($endGraphs)); + expect(count($initialViews))->toBe(count($endViews)); + +}); + +test('migrate:fresh --drop-types', function () { + $path = [ + realpath(__DIR__ . '/../../TestSetup/Database/Migrations'), + ]; + + $this->artisan('migrate:fresh', [ + '--path' => [ + database_path('migrations'), + realpath(__DIR__ . '/../../TestSetup/Database/Migrations'), + realpath(__DIR__ . '/../../vendor/orchestra/testbench-core/laravel/migrations/'), + ], + '--realpath' => true, + '--seed' => true, + '--seeder' => DatabaseSeeder::class, + '--drop-types' => true, + + ])->assertExitCode(0); +})->throws('This database driver does not support dropping all types.'); diff --git a/tests/Console/MigrateInstallCommandTest.php b/tests/Console/MigrateInstallCommandTest.php index a483ddf..c7a832a 100644 --- a/tests/Console/MigrateInstallCommandTest.php +++ b/tests/Console/MigrateInstallCommandTest.php @@ -34,4 +34,4 @@ test('migrate:install --database=none', function () { $this->artisan('migrate:install', ['--database' => 'none'])->assertExitCode(0); -})->throws(ErrorException::class); +})->throws(InvalidArgumentException::class); diff --git a/tests/Console/MigrateRefreshCommandTest.php b/tests/Console/MigrateRefreshCommandTest.php index a61fda2..eca1d1c 100644 --- a/tests/Console/MigrateRefreshCommandTest.php +++ b/tests/Console/MigrateRefreshCommandTest.php @@ -24,7 +24,7 @@ ])->assertExitCode(0); $collections = $this->schemaManager->getCollections(true); - expect(count($collections))->toBe(15); + expect(count($collections))->toBe($this->tableCount); }); test('migrate:refresh --database=arangodb', function () { @@ -45,7 +45,7 @@ ])->assertExitCode(0); $collections = $this->schemaManager->getCollections(true); - expect(count($collections))->toBe(15); + expect(count($collections))->toBe($this->tableCount); }); test('migrate:refresh --database=none', function () { diff --git a/tests/Console/ModelMakeCommandTest.php b/tests/Console/ModelMakeCommandTest.php index 11b6e12..f9ac39d 100644 --- a/tests/Console/ModelMakeCommandTest.php +++ b/tests/Console/ModelMakeCommandTest.php @@ -71,7 +71,7 @@ $migrationFiles = scandir($migrationPath); - foreach($migrationFiles as $file) { + foreach ($migrationFiles as $file) { if (in_array($file, ['.', '..', '.gitkeep'])) { continue; } @@ -90,7 +90,7 @@ $migrationFiles = scandir($migrationPath); - foreach($migrationFiles as $file) { + foreach ($migrationFiles as $file) { if (in_array($file, ['.', '..', '.gitkeep'])) { continue; } diff --git a/tests/Console/ShowModelCommandTest.php b/tests/Console/ShowModelCommandTest.php new file mode 100644 index 0000000..c1e2e28 --- /dev/null +++ b/tests/Console/ShowModelCommandTest.php @@ -0,0 +1,44 @@ +artisan('model:show') + ->assertFailed(); +})->throws('Not enough arguments (missing: "model")'); + +test('model:show \\TestSetup\\Models\\Character', function () { + $this->artisan('model:show', ['model' => '\\TestSetup\\Models\\Character']) + ->expectsOutputToContain('arangodb') + ->expectsOutputToContain('characters') + ->expectsOutputToContain('traditional') + ->expectsOutputToContain('computed') + ->assertSuccessful(); +}); + +test('model:show \\TestSetup\\Models\\Character --json', function () { + $this->artisan('model:show', ['model' => '\\TestSetup\\Models\\Character']) + ->expectsOutputToContain('arangodb') + ->expectsOutputToContain('characters') + ->expectsOutputToContain('traditional') + ->expectsOutputToContain('computed') + ->assertSuccessful(); +}); + +test('model:show \\TestSetup\\Models\\Child', function () { + $this->artisan('model:show', ['model' => '\\TestSetup\\Models\\Child']) + ->expectsOutputToContain('arangodb') + ->expectsOutputToContain('children') + ->expectsOutputToContain('traditional') + ->doesntExpectOutput('computed') + ->assertSuccessful(); +}); + +test('model:show \\TestSetup\\Models\\House', function () { + $this->artisan('model:show', ['model' => '\\TestSetup\\Models\\House']) + ->expectsOutputToContain('arangodb') + ->expectsOutputToContain('houses') + ->expectsOutputToContain('traditional') + ->doesntExpectOutput('computed') + ->assertSuccessful(); +}); diff --git a/tests/Console/WipeCommandTest.php b/tests/Console/WipeCommandTest.php new file mode 100644 index 0000000..49a398d --- /dev/null +++ b/tests/Console/WipeCommandTest.php @@ -0,0 +1,158 @@ +schemaManager = $this->connection->getArangoClient()->schema(); +}); + +afterEach(function () { + $path = [ + realpath(__DIR__ . '/../../TestSetup/Database/Migrations'), + ]; + + $this->artisan('migrate:fresh', [ + '--path' => [ + database_path('migrations'), + realpath(__DIR__ . '/../../TestSetup/Database/Migrations'), + realpath(__DIR__ . '/../../vendor/orchestra/testbench-core/laravel/migrations/'), + ], + '--realpath' => true, + '--seed' => true, + '--seeder' => DatabaseSeeder::class, + '--drop-all' => true, + ]); +}); + +test('db:wipe', function () { + $this->artisan('db:wipe')->assertExitCode(0); + + $views = $this->schemaManager->getViews(); + expect(count($views))->toBe(2); +}); + +test('db:wipe --database=arangodb', function () { + $this->artisan('db:wipe', [ + '--database' => 'arangodb', + ])->assertExitCode(0); + + $views = $this->schemaManager->getViews(); + expect(count($views))->toBe(2); +}); + +test('migrate:fresh --database=none', function () { + $this->artisan('db:wipe', [ + '--database' => 'none', + ])->assertExitCode(0); + + $views = $this->schemaManager->getViews(); + expect(count($views))->toBe(2); +})->throws(InvalidArgumentException::class); + +test('db:wipe --drop-views', function () { + $this->artisan('db:wipe', [ + '--drop-views' => true, + ])->assertExitCode(0); + + $views = $this->schemaManager->getViews(); + expect(count($views))->toBe(0); +}); + +test('db:wipe --drop-analyzers', function () { + $initialAnalyzers = $this->schemaManager->getAnalyzers(); + + $schemaManager = $this->connection->getArangoClient()->schema(); + if (!$schemaManager->hasAnalyzer('dropMyAnalyzer')) { + Schema::createAnalyzer( + 'dropMyAnalyzer', + 'identity', + ); + } + + $this->artisan('db:wipe', [ + '--drop-analyzers' => true, + ])->assertExitCode(0); + + $endAnalyzers = $this->schemaManager->getAnalyzers(); + expect(count($initialAnalyzers))->toBe(count($endAnalyzers)); +}); + +test('db:wipe --drop-graphs', function () { + $schemaManager = $this->connection->getArangoClient()->schema(); + if (!$schemaManager->hasGraph('dropMyGraph')) { + Schema::createGraph( + 'dropMyGraph', + [ + 'edgeDefinitions' => [ + [ + 'collection' => 'children', + 'from' => ['characters'], + 'to' => ['characters'], + ], + ], + ], + true, + ); + } + + + $this->artisan('db:wipe', [ + '--drop-graphs' => true, + ])->assertExitCode(0); + + $graphs = $this->schemaManager->getGraphs(); + expect(count($graphs))->toBe(0); +}); + +test('db:wipe --drop-all', function () { + $initialAnalyzers = $this->schemaManager->getAnalyzers(); + $initialViews = $this->schemaManager->getViews(); + $initialGraphs = $this->schemaManager->getGraphs(); + + $schemaManager = $this->connection->getArangoClient()->schema(); + if (!$schemaManager->hasAnalyzer('dropMyAnalyzer')) { + Schema::createAnalyzer( + 'dropMyAnalyzer', + 'identity', + ); + } + + if (!$schemaManager->hasGraph('dropMyGraph')) { + Schema::createGraph( + 'dropMyGraph', + [ + 'edgeDefinitions' => [ + [ + 'collection' => 'children', + 'from' => ['characters'], + 'to' => ['characters'], + ], + ], + ], + true, + ); + } + + if (!$schemaManager->hasView('dropViewTest')) { + Schema::createView('dropViewTest', []); + } + + $this->artisan('db:wipe', [ + '--drop-all' => true, + ])->assertExitCode(0); + + $endAnalyzers = $this->schemaManager->getAnalyzers(); + $endGraphs = $this->schemaManager->getGraphs(); + $endViews = $this->schemaManager->getViews(); + + expect(count($initialAnalyzers))->toBe(count($endAnalyzers)); + expect(count($initialGraphs))->toBe(count($endGraphs)); + expect(count($initialViews))->toBe(2); + expect(count($endViews))->toBe(0); +}); + +test('db:wipe --drop-types', function () { + $this->artisan('db:wipe', [ + '--drop-types' => true, + ])->assertExitCode(0); +})->throws('This database driver does not support dropping all types.'); diff --git a/tests/Database/ConnectionTest.php b/tests/Database/ConnectionTest.php index bf85cd2..a5dab05 100644 --- a/tests/Database/ConnectionTest.php +++ b/tests/Database/ConnectionTest.php @@ -122,3 +122,17 @@ // // Schema::hasTable('dummy'); //})->throws(QueryException::class); + +test('threadCount', function () { + $connection = $this->connection; + + expect($connection->threadCount())->toBeInt(); + expect($connection->threadCount())->toBeGreaterThan(-1); +}); + +test('getServerVersion', function () { + $connection = $this->connection; + + expect($connection->getServerVersion())->toBeString(); + expect($connection->getServerVersion())->toMatch('/^\d+\.\d+\.\d+$/i'); +}); diff --git a/tests/Eloquent/BelongsToManyTest.php b/tests/Eloquent/BelongsToManyTest.php index 8692420..cff60db 100644 --- a/tests/Eloquent/BelongsToManyTest.php +++ b/tests/Eloquent/BelongsToManyTest.php @@ -51,7 +51,7 @@ test('attach', function () { $child = Character::find('JonSnow'); - $lyannaStark = Character::firstOrCreate( + Character::firstOrCreate( [ 'id' => 'LyannaStark', 'name' => 'Lyanna', @@ -68,7 +68,7 @@ $child->parents()->attach($lyannaStark); $child->save(); - $reloadedChild = Character::find('JonSnow'); + Character::find('JonSnow'); $parents = $child->parents; expect($parents[0]->id)->toEqual('NedStark'); @@ -157,3 +157,29 @@ expect($char->tags[1]->pivot->tag_id)->toBeString(); expect($char->tags[1]->pivot->tag_id)->toBe('2'); }); + +test('with', function () { + $parent = Character::with('children')->find('NedStark'); + + expect($parent->children)->toHaveCount(5); + expect($parent->children->first()->id)->toEqual('RobbStark'); +}); + +test('with on multiple models', function () { + $characters = Character::with('children')->where('surname', 'Stark')->get(); + + expect($characters)->toHaveCount(6); + expect($characters[0]->id)->toEqual('NedStark'); + expect($characters[0]->children->first()->id)->toEqual('AryaStark'); + expect($characters[0]->children)->toHaveCount(5); + expect($characters[1]->id)->toEqual('CatelynStark'); + expect($characters[1]->children)->toHaveCount(4); +}); + +test('load', function () { + $parent = Character::find('NedStark'); + $parent->load('children'); + + expect($parent->children)->toHaveCount(5); + expect($parent->children->first()->id)->toEqual('RobbStark'); +}); diff --git a/tests/Eloquent/BelongsToTest.php b/tests/Eloquent/BelongsToTest.php index 843498e..65dfdee 100644 --- a/tests/Eloquent/BelongsToTest.php +++ b/tests/Eloquent/BelongsToTest.php @@ -5,6 +5,7 @@ use LaravelFreelancerNL\Aranguent\Testing\DatabaseTransactions; use Mockery as M; use TestSetup\Models\Character; +use TestSetup\Models\House; use TestSetup\Models\Location; uses( @@ -82,3 +83,29 @@ expect($location->leader)->toBeInstanceOf(Character::class); expect($location->leader->id)->toEqual('SansaStark'); }); + + +test('with on single model', function () { + $house = House::with('head')->find('lannister'); + + expect($house->head)->toBeInstanceOf(Character::class); + expect($house->head->id)->toEqual('TywinLannister'); +}); + + +test('with on multiple model', function () { + $houses = House::with('head')->get(); + + expect($houses->count())->toBe(3); + expect($houses->first()->head)->toBeInstanceOf(Character::class); + expect($houses->first()->head->id)->toEqual('TywinLannister'); +}); + + +test('load', function () { + $house = House::find('lannister'); + $house->load('head'); + + expect($house->head)->toBeInstanceOf(Character::class); + expect($house->head->id)->toEqual('TywinLannister'); +}); diff --git a/tests/Eloquent/CastAttributesTest.php b/tests/Eloquent/CastAttributesTest.php index 047bd40..74cbc51 100644 --- a/tests/Eloquent/CastAttributesTest.php +++ b/tests/Eloquent/CastAttributesTest.php @@ -30,6 +30,12 @@ $profile = [ 'firstName' => fake()->firstName, 'lastName' => fake()->lastName, + 'address' => [ + 'streetAddress' => fake()->streetAddress, + 'city' => fake()->city, + 'postcode' => fake()->postcode, + 'country' => fake()->country, + ], ]; $user = User::create([ @@ -43,13 +49,22 @@ expect($user->profileAsArray)->toBeArray(); expect($refreshedUser->profileAsArray)->toBeArray(); + expect($refreshedUser->profileAsArray['address'])->toBeArray(); + expect($retrievedUser->profileAsArray)->toBeObject(); + expect($retrievedUser->profileAsArray->address)->toBeObject(); }); test('Cast attribute to ArrayObject', function () { $profile = [ 'firstName' => fake()->firstName, 'lastName' => fake()->lastName, + 'address' => [ + 'streetAddress' => fake()->streetAddress, + 'city' => fake()->city, + 'postcode' => fake()->postcode, + 'country' => fake()->country, + ], ]; $user = User::create([ @@ -66,8 +81,11 @@ expect($user->profileAsArrayObjectCast)->toBeInstanceOf(ArrayObject::class); expect($refreshedUser->profileAsArrayObjectCast)->toBeInstanceOf(ArrayObject::class); + expect($refreshedUser->profileAsArrayObjectCast->address)->toBeArray(); + expect($refreshedUser->profileAsArrayObjectCast->age)->toBe(24); expect($retrievedUser->profileAsArrayObjectCast)->toBeObject(); + expect($retrievedUser->profileAsArrayObjectCast->address)->toBeObject(); }); test('Cast attribute to object', function () { diff --git a/tests/Eloquent/HasManyTest.php b/tests/Eloquent/HasManyTest.php index 109621a..23d7698 100644 --- a/tests/Eloquent/HasManyTest.php +++ b/tests/Eloquent/HasManyTest.php @@ -89,3 +89,11 @@ expect($location->inhabitants->first())->toBeInstanceOf(Character::class); expect($location->inhabitants->first()->id)->toEqual('NedStark'); }); + +test('with on multiple models', function () { + $locations = Location::with('inhabitants')->get(); + + expect($locations->first()->inhabitants->first())->toBeInstanceOf(Character::class); + expect($locations->first()->inhabitants)->toHaveCount(2); + expect($locations[8]->inhabitants)->toHaveCount(0); +}); diff --git a/tests/Eloquent/ModelTest.php b/tests/Eloquent/ModelTest.php index 2d72a57..05cd79f 100644 --- a/tests/Eloquent/ModelTest.php +++ b/tests/Eloquent/ModelTest.php @@ -2,6 +2,8 @@ use LaravelFreelancerNL\Aranguent\Testing\DatabaseTransactions; use TestSetup\Models\Character; +use TestSetup\Models\User; +use LaravelFreelancerNL\Aranguent\Exceptions\QueryException; uses( DatabaseTransactions::class, @@ -18,21 +20,49 @@ expect($fresh->age)->toBe(($initialAge + 1)); }); -test('update or create', function () { - $character = Character::first(); - $initialAge = $character->age; - $newAge = ($initialAge + 1); +test('updateOrCreate', function () { + $userData = [ + "username" => "Dunk", + "email" => "d.the.tall@hedgeknight.com", + ]; - $character->updateOrCreate(['age' => $initialAge], ['age' => $newAge]); + $user1 = User::updateOrCreate($userData); - $fresh = $character->fresh(); + $user2 = User::where("email", "d.the.tall@hedgeknight.com")->first(); - expect($fresh->age)->toBe($newAge); + expect($user1->_id)->toBe($user2->_id); }); -test('upsert', function () { - $this->skipTestOnArangoVersionsBefore('3.7'); +test('updateOrCreate runs twice', function () { + $userData = [ + "username" => "Dunk", + "email" => "d.the.tall@hedgeknight.com", + ]; + $user1 = User::updateOrCreate($userData); + + $user2 = User::updateOrCreate($userData); + + expect($user1->_id)->toBe($user2->_id); +}); + +test('updateOrCreate throws error on unique key if data is different', function () { + $userData = [ + "username" => "Dunk", + "email" => "d.the.tall@hedgeknight.com", + ]; + $user1 = User::updateOrCreate($userData); + + $userData = [ + "username" => "Duncan the Tall", + "email" => "d.the.tall@hedgeknight.com", + ]; + $user2 = User::updateOrCreate($userData); + + expect($user1->_id)->toBe($user2->_id); +})->throws(QueryException::class); + +test('upsert', function () { Character::upsert( [ [ @@ -63,6 +93,28 @@ expect($jaime->alive)->toBeFalse(); }); +test('upsert runs twice', function () { + $userData = [ + "username" => "Dunk", + "email" => "d.the.tall@hedgeknight.com", + ]; + + User::upsert([$userData], ['email'], ['username']); + + $userData = [ + "username" => "Duncan the Tall", + "email" => "d.the.tall@hedgeknight.com", + ]; + + $result = User::upsert([$userData], ['email'], ['username']); + + $user = User::where("email", "d.the.tall@hedgeknight.com")->first(); + + expect($result)->toBe(1); + expect($user->username)->toBe("Duncan the Tall"); +}); + + test('delete model', function () { $character = Character::first(); @@ -131,3 +183,63 @@ expect($ned->id)->toEqual('NedStarkIsDead'); expect($ned->_id)->toEqual('characters/NedStarkIsDead'); }); + +test('firstOrCreate', function () { + $userData = [ + "username" => "Dunk", + "email" => "d.the.tall@hedgeknight.com", + ]; + + $user = User::firstOrCreate($userData); + + $result = DB::table('users') + ->where('email', 'd.the.tall@hedgeknight.com') + ->first(); + + expect($result->_id)->toBe($user->_id); +}); + +test('firstOrCreate runs twice without error', function () { + $userData = [ + "username" => "Dunk", + "email" => "d.the.tall@hedgeknight.com", + ]; + + $user1 = User::firstOrCreate($userData); + $user2 = User::firstOrCreate($userData); + + expect($user1->_id)->toBe($user2->_id); +}); + +test('firstOrCreate throws error if data is different', function () { + $userData = [ + "username" => "Dunk", + "email" => "d.the.tall@hedgeknight.com", + ]; + + $user1 = User::firstOrCreate($userData); + + $userData = [ + "username" => "Duncan the Tall", + "email" => "d.the.tall@hedgeknight.com", + ]; + $user2 = User::firstOrCreate($userData); + + expect($user1->_id)->toBe($user2->_id); +})->throws(QueryException::class); + + +test('createOrFirst', function () { + $userData = [ + "username" => "Dunk", + "email" => "d.the.tall@hedgeknight.com", + ]; + + $user = User::createOrFirst($userData); + + $result = DB::table('users') + ->where('email', 'd.the.tall@hedgeknight.com') + ->first(); + + expect($result->_id)->toBe($user->_id); +}); diff --git a/tests/Eloquent/MorphToTest.php b/tests/Eloquent/MorphToTest.php index 51645cf..5674cca 100644 --- a/tests/Eloquent/MorphToTest.php +++ b/tests/Eloquent/MorphToTest.php @@ -1,5 +1,6 @@ capturable)->toBeInstanceOf(Character::class); expect($location->capturable->id)->toEqual('TheonGreyjoy'); }); + +test('whereHasMorph', function () { + $locations = Location::whereHasMorph( + 'capturable', + Character::class, + function (Builder $query) { + $query->where('_key', 'TheonGreyjoy'); + }, + )->get(); + + expect(count($locations))->toEqual(1); +}); + +test('orWhereHasMorph', function () { + $locations = Location::where(function (Builder $query) { + $query->whereHasMorph( + 'capturable', + Character::class, + function (Builder $query) { + $query->where('id', 'TheonGreyjoy'); + }, + ) + ->orWhereHasMorph( + 'capturable', + Character::class, + function (Builder $query) { + $query->where('id', 'DaenerysTargaryen'); + }, + ); + })->get(); + + expect(count($locations))->toEqual(6); +}); + +test('whereMorphRelation ', function () { + $locations = Location::whereMorphRelation( + 'capturable', + Character::class, + '_key', + 'TheonGreyjoy', + ) + ->get(); + + expect(count($locations))->toEqual(1); +}); + +test('orWhereMorphRelation', function () { + $locations = Location::where(function (Builder $query) { + $query->whereMorphRelation( + 'capturable', + Character::class, + 'id', + 'TheonGreyjoy', + ) + ->orWhereMorphRelation( + 'capturable', + Character::class, + 'id', + 'DaenerysTargaryen', + ); + })->get(); + + expect($locations->count())->toEqual(6); +}); + +test('whereDoesntHaveMorph', function () { + $locations = Location::whereDoesntHaveMorph( + 'capturable', + Character::class, + function (Builder $query) { + $query->where('id', 'DaenerysTargaryen'); + }, + )->get(); + + expect(count($locations))->toEqual(1); +}); + +test('orWhereDoesntHaveMorph', function () { + $locations = Location::where(function (Builder $query) { + $query->whereHasMorph( + 'capturable', + Character::class, + function (Builder $query) { + $query->where('alive', true); + }, + ) + ->orWhereDoesntHaveMorph( + 'capturable', + Character::class, + function (Builder $query) { + $query->where('age', '<', 20); + }, + ); + })->get(); + + expect(count($locations))->toEqual(6); +}); + + +test('whereMorphDoesntHaveRelation', function () { + $locations = Location::whereMorphDoesntHaveRelation( + 'capturable', + Character::class, + 'id', + 'TheonGreyjoy', + )->get(); + + expect(count($locations))->toEqual(5); +}); + +test('orWhereMorphDoesntHaveRelation', function () { + $locations = Location::where(function (Builder $query) { + $query->whereMorphDoesntHaveRelation( + 'capturable', + Character::class, + 'id', + 'DaenerysTargaryen', + ) + ->orWhereMorphDoesntHaveRelation( + 'capturable', + Character::class, + 'age', + '<', + 20, + ); + })->get(); + + expect(count($locations))->toEqual(1); +}); diff --git a/tests/Eloquent/RelationshipQueriesTest.php b/tests/Eloquent/RelationshipQueriesTest.php index 0740782..5f43160 100644 --- a/tests/Eloquent/RelationshipQueriesTest.php +++ b/tests/Eloquent/RelationshipQueriesTest.php @@ -1,7 +1,10 @@ toEqual(1); }); +test('has morph', function () { + $characters = Character::has('tags')->get(); + + expect(count($characters))->toEqual(2); +}); + + +test('orHas', function () { + $characters = Character::has('leads') + ->orHas('captured') + ->get(); + + expect(count($characters))->toEqual(4); +}); + test('doesntHave', function () { $characters = Character::doesntHave('leads')->get(); + expect(count($characters))->toEqual(40); }); -test('has on morphed relation', function () { - $characters = Character::has('tags')->get(); +test('orDoesntHave', function () { + $characters = Character::where(function (Builder $query) { + $query->doesntHave('leads') + ->orDoesntHave('conquered'); + }) + ->get(); - expect(count($characters))->toEqual(2); + $daenarys = $characters->first(function (Character $character) { + return $character->id === 'DaenerysTargaryen'; + }); + + expect(count($characters))->toEqual(42); + expect($daenarys)->toBeNull(); +}); + +test('whereHas', function () { + $locations = Location::whereHas('leader', function (Builder $query) { + $query->where('age', '<', 30); + }) + ->distinct() + ->pluck('led_by'); + + expect($locations->count())->toBe(2); + expect($locations[0])->toBe('DaenerysTargaryen'); + expect($locations[1])->toBe('SansaStark'); +}); + +test('orWhereHas', function () { + $locations = Location::where(function (Builder $query) { + $query->whereHas('leader', function (Builder $query) { + $query->where('age', '<', 15); + })->orWhereHas('leader', function (Builder $query) { + $query->where('age', '>', 30); + }); + }) + ->distinct() + ->pluck('led_by'); + + expect($locations->count())->toBe(2); + expect($locations[0])->toBe('CerseiLannister'); + expect($locations[1])->toBe('SansaStark'); +}); + +test('whereDoesntHave', function () { + $characters = Character::whereDoesntHave('leads', function (Builder $query) { + $query->where('name', 'Astapor'); + })->get(); + + $daenarys = $characters->first(function (Character $character) { + return $character->id === 'DaenerysTargaryen'; + }); + expect($characters->count())->toBe(42); + expect($daenarys)->toBeNull(); +}); + +test('orWhereDoesntHave', function () { + $houses = House::where(function (Builder $query) { + $query->whereDoesntHave('head', function (Builder $query) { + $query->where('age', '<', 20); + }) + ->orWhereDoesntHave('head', function (Builder $query) { + $query->whereNull('age'); + }); + })->get(); + + expect($houses[0]->name)->toBe('Stark'); + expect($houses[1]->name)->toBe('Targaryen'); +}); + + + +test('whereRelation', function () { + $locations = Location::whereRelation('leader', 'age', '<', 30) + ->distinct() + ->pluck('led_by'); + + expect($locations->count())->toBe(2); + expect($locations[0])->toBe('DaenerysTargaryen'); + expect($locations[1])->toBe('SansaStark'); +}); + +test('orWhereRelation', function () { + $locations = Location::where(function (Builder $query) { + $query->whereRelation('leader', 'age', '<', 15) + ->orWhereRelation('leader', 'age', '>', 30); + }) + ->distinct() + ->pluck('led_by'); + + expect($locations->count())->toBe(2); + expect($locations[0])->toBe('CerseiLannister'); + expect($locations[1])->toBe('SansaStark'); +}); + +test('whereDoesntHaveRelation', function () { + $characters = Character::whereDoesntHaveRelation('leads', 'name', 'Astapor')->get(); + + $daenarys = $characters->first(function (Character $character) { + return $character->id === 'DaenerysTargaryen'; + }); + + expect($characters->count())->toBe(42); + expect($daenarys)->toBeNull(); +}); + +test('orWhereDoesntHaveRelation', function () { + $houses = House::where(function (Builder $query) { + $query->whereDoesntHaveRelation('head', 'age', '<', 20) + ->orWhereDoesntHaveRelation('head', 'age', null); + })->get(); + + expect($houses[0]->name)->toBe('Stark'); + expect($houses[1]->name)->toBe('Targaryen'); }); test('withCount', function () { diff --git a/tests/Migrations/MigrationRepositoryTest.php b/tests/Migrations/MigrationRepositoryTest.php index 5818911..d2f0fa0 100644 --- a/tests/Migrations/MigrationRepositoryTest.php +++ b/tests/Migrations/MigrationRepositoryTest.php @@ -10,8 +10,8 @@ }); afterEach(function () { - $migrations = $this->databaseMigrationRepository->getMigrations(11); - foreach($migrations as $migration) { + $migrations = $this->databaseMigrationRepository->getMigrations(12); + foreach ($migrations as $migration) { $this->databaseMigrationRepository->delete($migration); } }); @@ -42,7 +42,7 @@ $this->databaseMigrationRepository->delete($migration); $results = $this->databaseMigrationRepository->getRan(); - ray('delete getRan', $results); + expect($results)->toBeEmpty(); }); @@ -92,7 +92,7 @@ "getMigrationBatches2" => 33, ]; - foreach($batches as $migration => $batch) { + foreach ($batches as $migration => $batch) { $this->databaseMigrationRepository->log($migration, $batch); } diff --git a/tests/Pest.php b/tests/Pest.php index f76a4c4..63ed5ce 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -54,9 +54,9 @@ /** @link https://pestphp.com/docs/helpers */ -function getBuilder() +function getBuilder($connection) { - $grammar = new Grammar(); + $grammar = new Grammar($connection); $processor = m::mock(Processor::class); return new Builder(m::mock(Connection::class), $grammar, $processor); @@ -78,23 +78,6 @@ function refreshDatabase() ]); } -/** - * The parameters that should be used when running "migrate:fresh". - * - * @return array - */ -//function migrateFreshUsing() -//{ -// ray('my migrateFreshUsing Pest.php'); -// return [ -// '--realpath' => true, -// '--path' => __DIR__ . '/../vendor/orchestra/testbench-core/laravel/migrations/', -// '--seed' => true, -// '--seeder' => DatabaseSeeder::class, -// ]; -//} - - function runCommand($command, $input = []) { return $command->run(new ArrayInput($input), new NullOutput()); diff --git a/tests/Query/DataRetrievalTest.php b/tests/Query/DataRetrievalTest.php index 845c310..c3df3c8 100644 --- a/tests/Query/DataRetrievalTest.php +++ b/tests/Query/DataRetrievalTest.php @@ -101,7 +101,7 @@ function ($query) use ($residenceId) { foreach ($characters as $character) { $count++; } - if($count > 20) { + if ($count > 20) { return false; } return true; diff --git a/tests/Query/DebugTest.php b/tests/Query/DebugTest.php index b555077..209c827 100644 --- a/tests/Query/DebugTest.php +++ b/tests/Query/DebugTest.php @@ -68,14 +68,14 @@ $query = DB::table('characters'); $query->where('name', 'Gilly'); - for($i = 0; $i < 9; $i++) { + for ($i = 0; $i < 9; $i++) { $query->orWhere('name', $names[$i]); } $aql = $query->toRawSql(); $rawAql = 'FOR characterDoc IN characters FILTER `characterDoc`.`name` == "Gilly"'; - for($i = 0; $i < 9; $i++) { + for ($i = 0; $i < 9; $i++) { $rawAql .= ' or `characterDoc`.`name` == "' . $names[$i] . '"'; } $rawAql .= ' RETURN characterDoc'; diff --git a/tests/Query/GrammarTest.php b/tests/Query/GrammarTest.php index 644e928..b35630c 100644 --- a/tests/Query/GrammarTest.php +++ b/tests/Query/GrammarTest.php @@ -1,7 +1,7 @@ connection); $builder = $builder->select(['id', '_id', 'email']) ->from('users'); @@ -12,7 +12,7 @@ }); test('wrap bypass', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder = $builder->select('*') ->from('users') ->where('i`d', '=', "a123"); diff --git a/tests/Query/IdKeyConversionTest.php b/tests/Query/IdKeyConversionTest.php index c659147..c6758fe 100644 --- a/tests/Query/IdKeyConversionTest.php +++ b/tests/Query/IdKeyConversionTest.php @@ -24,7 +24,7 @@ }); test('get id conversion single attribute', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder = $builder->select('id')->from('users'); $this->assertSame( diff --git a/tests/Query/InsertTest.php b/tests/Query/InsertTest.php index 23fa65a..fe6be9f 100644 --- a/tests/Query/InsertTest.php +++ b/tests/Query/InsertTest.php @@ -25,14 +25,14 @@ test('insert get id', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->getConnection()->shouldReceive('execute')->once()->andReturn(1); $result = $builder->from('users')->insertGetId(['email' => 'foo']); expect($result)->toEqual(1); }); -test('insert or ignore inserts data', function () { +test('insertOrIgnore inserts data', function () { $characterData = [ "_key" => "LyannaStark", "name" => "Lyanna", @@ -58,7 +58,7 @@ expect($result->count())->toBe(1); }); -test('insert or ignore doesnt error on duplicates', function () { +test('insertOrIgnore doesnt error on duplicates', function () { $characterData = [ "_key" => "LyannaStark", "name" => "Lyanna", @@ -78,6 +78,31 @@ expect($result->count())->toBe(1); }); +test('insertOrIgnore with unique index on non-primary fields', function () { + $userData = [ + "_key" => "LyannaStark", + "username" => "Lyanna Stark", + "email" => "l.stark@windsofwinter.com", + ]; + DB::table('users')->insertOrIgnore($userData); + + $result = DB::table('users') + ->first(); + + expect($result->_id)->toBe('users/LyannaStark'); + + $userData = [ + "username" => "Lya Stark", + "email" => "l.stark@windsofwinter.com", + ]; + DB::table('users')->insertOrIgnore($userData); + + $result = DB::table('users') + ->first(); + + expect($result->_id)->toBe('users/LyannaStark'); +}); + test('insert embedded empty array', function () { $characterData = [ "_key" => "LyannaStark", @@ -100,14 +125,41 @@ expect($result->first()->tags)->toBeEmpty(); }); -test('insert using', function () { +test('insertUsing', function () { // Let's give Baelish a user, what could possibly go wrong? $baelishes = DB::table('characters') ->where('surname', 'Baelish'); DB::table('users')->insertUsing(['name', 'surname'], $baelishes); - $user = DB::table('users')->first(); + $user = DB::table('users')->where("surname", "=", "Baelish")->first(); + + expect($user->surname)->toBe('Baelish'); +}); + +test('insertOrIgnoreUsing', function () { + // Let's give Baelish a user, what could possibly go wrong? Everyone trusts him... + $baelishes = DB::table('characters') + ->where('surname', 'Baelish'); + + DB::table('users')->insertOrIgnoreUsing(['name', 'surname'], $baelishes); + + $user = DB::table('users')->where("surname", "=", "Baelish")->first(); + + expect($user->surname)->toBe('Baelish'); +}); + +test("insertOrIgnoreUsing doesn't error on duplicates", function () { + // Let's give Baelish a user, what could possibly go wrong? Everyone trusts him... + $baelish = DB::table('characters') + ->where('surname', 'Baelish'); + + DB::table('users')->insertUsing(['name', 'surname'], $baelish); + + // Let's do it again. + DB::table('users')->insertOrIgnoreUsing(['name', 'surname'], $baelish); + + $user = DB::table('users')->where("surname", "=", "Baelish")->first(); expect($user->surname)->toBe('Baelish'); }); diff --git a/tests/Query/JoinTest.php b/tests/Query/JoinTest.php index 0294cfc..cc6f96d 100644 --- a/tests/Query/JoinTest.php +++ b/tests/Query/JoinTest.php @@ -48,7 +48,7 @@ $characters = $builder->get(); expect($characters)->toHaveCount(7); - expect($characters[0]->age)->toEqual(16); + expect($characters[0]->age)->toEqual($this->tableCount); expect($characters[0]->surname)->toEqual('Targaryen'); }); @@ -110,3 +110,44 @@ 'coordinate', ]); }); + +test('joinLateral', function () { + $controlledLocations = DB::table('locations') + ->whereColumn('locations.led_by', '==', 'characters.id') + ->limit(3); + + $leadingLadies = DB::table('characters') + ->joinLateral( + $controlledLocations, + 'controlled_territory', + ) + ->orderBy('name') + ->get(); + + expect($leadingLadies)->toHaveCount(6); +}); + + +test('joinLateral with selected fields', function () { + $controlledLocations = DB::table('locations') + ->select('id as location_id', 'name as location_name') + ->whereColumn('locations.led_by', '==', 'characters.id') + ->orderBy('name') + ->limit(3); + + $leadingLadies = DB::table('characters') + ->select('id', 'name', 'controlled_territory.location_name as territory_name') + ->joinLateral( + $controlledLocations, + 'controlled_territory', + ) + ->orderBy('name') + ->get(); + + expect($leadingLadies)->toHaveCount(6); + + expect(($leadingLadies[1])->name)->toBe('Cersei'); + expect(($leadingLadies[2])->name)->toBe('Daenerys'); + expect(($leadingLadies[2])->territory_name)->toBe('Astapor'); + expect(($leadingLadies[5])->name)->toBe('Sansa'); +}); diff --git a/tests/Query/OrderingTest.php b/tests/Query/OrderingTest.php index f9648b2..3ce2a2c 100644 --- a/tests/Query/OrderingTest.php +++ b/tests/Query/OrderingTest.php @@ -5,7 +5,7 @@ use Illuminate\Support\Facades\DB; test('orderBy', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*')->from('users')->orderBy('email')->orderBy('age', 'desc'); $this->assertSame( 'FOR userDoc IN users SORT `userDoc`.`email` ASC, `userDoc`.`age` DESC RETURN userDoc', @@ -46,7 +46,7 @@ }); test('orderByRaw', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*')->from('users')->orderByRaw('userDoc.age @direction', ['@direction' => 'ASC']); $this->assertSame( 'FOR userDoc IN users SORT userDoc.age @direction RETURN userDoc', @@ -56,7 +56,7 @@ test('reorder', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*') ->from('users') ->orderByRaw('userDoc.age @direction', ['@direction' => 'ASC']) diff --git a/tests/Query/SelectTest.php b/tests/Query/SelectTest.php index 89d51af..5943e2e 100644 --- a/tests/Query/SelectTest.php +++ b/tests/Query/SelectTest.php @@ -7,7 +7,7 @@ expect($results)->toHaveCount(43); - expect(count((array) $results[0]))->toBe(9); + expect(count((array) $results[0]))->toBe(10); }); test('basic select with specific column', function () { diff --git a/tests/Query/UnionTest.php b/tests/Query/UnionTest.php index d4f2ecd..43e1605 100644 --- a/tests/Query/UnionTest.php +++ b/tests/Query/UnionTest.php @@ -77,3 +77,63 @@ expect(($results->first())->name)->toBe('Robert'); expect(($results->last())->name)->toBe('Roose'); }); + +test('union aggregate average', function () { + $charactersWithoutAge = DB::table('characters') + ->where('surname', 'Stark'); + + $averageAge = DB::table('characters') + ->where('surname', 'Lannister') + ->union($charactersWithoutAge) + ->avg('age'); + + expect($averageAge)->toBe(27.375); +}); + +test('union aggregate count', function () { + $charactersWithoutAge = DB::table('characters') + ->where('surname', 'Stark'); + + $averageAge = DB::table('characters') + ->where('surname', 'Lannister') + ->union($charactersWithoutAge) + ->count(); + + expect($averageAge)->toBe(10); +}); + +test('union aggregate min', function () { + $charactersWithoutAge = DB::table('characters') + ->where('surname', 'Stark'); + + $averageAge = DB::table('characters') + ->where('surname', 'Lannister') + ->union($charactersWithoutAge) + ->min('age'); + + expect($averageAge)->toBe(10); +}); + +test('union aggregate max', function () { + $charactersWithoutAge = DB::table('characters') + ->where('surname', 'Stark'); + + $averageAge = DB::table('characters') + ->where('surname', 'Lannister') + ->union($charactersWithoutAge) + ->max('age'); + + expect($averageAge)->toBe(41); +}); + +test('union aggregate sum', function () { + $charactersWithoutAge = DB::table('characters') + ->where('surname', 'Stark'); + + $averageAge = DB::table('characters') + ->where('surname', 'Lannister') + ->union($charactersWithoutAge) + ->sum('age'); + + expect($averageAge)->toBe(219); +}); diff --git a/tests/Query/UpdateTest.php b/tests/Query/UpdateTest.php index 0e9fde6..d1692d1 100644 --- a/tests/Query/UpdateTest.php +++ b/tests/Query/UpdateTest.php @@ -67,6 +67,22 @@ ->and($result->en->words)->toBe($words); }); +test('updateOrInsert without values', function () { + $exists = DB::table('characters')->where('id', 'JaimeLannister')->exists(); + expect($exists)->toBeTrue(); + + $result = DB::table('characters') + ->updateOrInsert(["_key" => "JaimeLannister"], []); + + $exists = DB::table('characters')->where('id', 'JaimeLannister')->exists(); + + expect($result)->toBeTrue(); + expect($exists)->toBeTrue(); +}); + + + + test('updateOrInsert updates', function () { DB::table('characters') ->updateOrInsert(["_key" => "NedStark"], ["alive" => false, "age" => 42]); @@ -96,6 +112,42 @@ expect($result)->toBeTrue(); }); +test('updateOrInsert inserts with callback', function () { + $result = DB::table('characters')->where('id', 'LyannaStark')->exists(); + + expect($result)->toBeFalse(); + + $data = [ + "name" => "Lyanna", + "surname" => "Stark", + "alive" => false, + "age" => 25, + "residence_id" => "winterfell", + "tags" => [], + ]; + + DB::table('characters')->updateOrInsert( + ["_key" => "LyannaStark"], + function ($exists) use ($data) { + if ($exists) { + return [ + "name" => "Lyanna", + "surname" => "Stark", + ]; + } + + return [ + "name" => "Lyanna", + "surname" => "Stark", + "alive" => $data['alive'], + ]; + }, + ); + + $result = DB::table('characters')->where('id', 'LyannaStark')->exists(); + + expect($result)->toBeTrue(); +}); test('increment', function () { $youngNed = DB::table('characters')->where('id', 'NedStark')->first(); diff --git a/tests/Query/WheresTest.php b/tests/Query/WheresTest.php index 7f85a4e..522f296 100644 --- a/tests/Query/WheresTest.php +++ b/tests/Query/WheresTest.php @@ -4,7 +4,7 @@ use TestSetup\Models\Character; test('basic wheres', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder = $builder->select('*') ->from('users') ->where('id', '=', "a123"); @@ -18,7 +18,7 @@ }); test('basic wheres with multiple predicates', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*') ->from('users') ->where('id', '=', 1) @@ -35,7 +35,7 @@ }); test('basic or wheres', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*') ->from('users') ->where('id', '==', 1) @@ -50,7 +50,7 @@ }); test('where operator conversion', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*') ->from('users') ->where('email', '=', 'email@example.com') @@ -67,8 +67,23 @@ ); }); +test('where =~ operator', function () { + $builder = getBuilder($this->connection); + $builder->select('*') + ->from('users') + ->where('email', '=~', 'email@example.com'); + + $this->assertSame( + 'FOR userDoc IN users ' + . 'FILTER `userDoc`.`email` =~ @' + . $builder->getQueryId() + . '_where_1 RETURN userDoc', + $builder->toSql(), + ); +}); + test('where json arrow conversion', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*') ->from('users') ->where('email->address', '=', 'email@example.com') @@ -86,7 +101,7 @@ }); test('where json contains', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*') ->from('users') ->whereJsonContains('options->languages', 'en'); @@ -101,7 +116,7 @@ }); test('where json length', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*') ->from('users') ->whereJsonLength('options->languages', '>', 'en'); @@ -116,7 +131,7 @@ }); test('where between', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*')->from('users')->whereBetween('votes', [1, 100]); $this->assertSame( @@ -130,7 +145,7 @@ }); test('where not between', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*')->from('users')->whereNotBetween('votes', [1, 100]); $this->assertSame( @@ -144,7 +159,7 @@ }); test('where between columns', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*')->from('users')->whereBetweenColumns('votes', ['min_vote', 'max_vote']); $this->assertSame( @@ -155,7 +170,7 @@ }); test('where column', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*')->from('users')->whereColumn('first_name', '=', 'last_name'); $this->assertSame( @@ -165,7 +180,7 @@ }); test('where column without operator', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*')->from('users')->whereColumn('first_name', 'last_name'); $this->assertSame( @@ -175,12 +190,12 @@ }); test('where nulls', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*')->from('users')->whereNull('_key'); expect($builder->toSql())->toBe('FOR userDoc IN users FILTER `userDoc`.`_key` == null RETURN userDoc'); expect($builder->getBindings())->toEqual([]); - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*') ->from('users') ->where('id', '=', 1) @@ -195,12 +210,12 @@ }); test('where not nulls', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*')->from('users')->whereNotNull('id'); expect($builder->toSql())->toBe('FOR userDoc IN users FILTER `userDoc`.`_key` != null RETURN userDoc'); expect($builder->getBindings())->toEqual([]); - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*') ->from('users') ->where('id', '>', 1) @@ -214,23 +229,32 @@ ); }); -test('where in', function () { - $builder = getBuilder(); +test('whereIn', function () { + $results = DB::table('characters') + ->whereIn( + 'characters.residence_id', + [ + "astapor", + "beyond-the-wall", + "dragonstone", + "king-s-landing", + "riverrun", + "the-red-keep", + "vaes-dothrak", + "winterfell", + "yunkai", + ], + )->get(); - $builder->select() - ->from('users') - ->whereIn('country', ['The Netherlands', 'Germany', 'Great-Britain']); - - $this->assertSame( - 'FOR userDoc IN users FILTER `userDoc`.`country` IN @' - . $builder->getQueryId() - . '_where_1 RETURN userDoc', - $builder->toSql(), - ); + expect($results)->toHaveCount(33); + expect($results->first()->_id)->toBe('characters/NedStark'); + expect($results->first()->residence_id)->toBe('winterfell'); }); + + test('where integer in raw', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select() ->from('users') @@ -243,7 +267,7 @@ }); test('where not in', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select() ->from('users') @@ -258,7 +282,7 @@ }); test('where integer not in raw', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select() ->from('users') @@ -271,7 +295,7 @@ }); test('where date', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*')->from('users')->whereDate('created_at', '2016-12-31'); $this->assertSame( @@ -283,7 +307,7 @@ }); test('where year', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*')->from('users')->whereYear('created_at', '2016'); $this->assertSame( @@ -295,7 +319,7 @@ }); test('where month', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*')->from('users')->whereMonth('created_at', '12'); $this->assertSame( @@ -307,7 +331,7 @@ }); test('where day', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*')->from('users')->whereDay('created_at', '31'); $this->assertSame( @@ -319,7 +343,7 @@ }); test('where time', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $builder->select('*')->from('users')->whereTime('created_at', '11:20:45'); $this->assertSame( @@ -403,7 +427,7 @@ }); test('where nested', function () { - $builder = getBuilder(); + $builder = getBuilder($this->connection); $query = $builder->select('*') ->from('characters') @@ -506,3 +530,208 @@ expect($results->count())->toBe(2); expect(($results->first())->name)->toBe('Stark'); }); + +test('whereNone', function () { + $query = \DB::table('houses') + ->whereNone(['en.coat-of-arms', 'en.description'], 'LIKE', '%war%'); + + $binds = $query->getBindings(); + $bindKeys = array_keys($binds); + + $this->assertSame( + 'FOR houseDoc IN houses FILTER not ( `houseDoc`.`en`.`coat-of-arms` LIKE @' . $bindKeys[0] + . ' or `houseDoc`.`en`.`description` LIKE @' . $bindKeys[1] + . ') RETURN houseDoc', + $query->toSql(), + ); + + $results = $query->get(); + expect($results->count())->toBe(1); + expect(($results->first())->name)->toBe('Targaryen'); +}); + +test('orWhereNone', function () { + $query = \DB::table('houses') + ->where('name', 'Stark') + ->orWhereNone(['en.coat-of-arms', 'en.description'], 'LIKE', '%war%'); + + $binds = $query->getBindings(); + $bindKeys = array_keys($binds); + + $this->assertSame( + 'FOR houseDoc IN houses FILTER `houseDoc`.`name` == @' . $bindKeys[0] + + . ' or not ( `houseDoc`.`en`.`coat-of-arms` LIKE @' . $bindKeys[1] + . ' or `houseDoc`.`en`.`description` LIKE @' . $bindKeys[2] + . ') RETURN houseDoc', + $query->toSql(), + ); + + $results = $query->get(); + expect($results->count())->toBe(2); + expect(($results->first())->name)->toBe('Stark'); +}); + + + +test('basic whereNot', function () { + $builder = getBuilder($this->connection); + $builder->select('*')->from('characters')->where('surname', 'Lannister')->whereNot('alive', true); + + $this->assertSame( + 'FOR characterDoc IN characters FILTER `characterDoc`.`surname` == @' + . $builder->getQueryId() + . '_where_1 and not `characterDoc`.`alive` == @' + . $builder->getQueryId() + . '_where_2 RETURN characterDoc', + $builder->toSql(), + ); +}); + +test('whereNot nested', function () { + $query = getBuilder($this->connection); + $query = $query + ->select('*') + ->from('characters') + ->where('alive', true) + ->whereNot(function ($query) { + $query->where('surname', 'lannister') + ->orWhere('age', '<', 20); + }); + + + $binds = $query->getBindings(); + $bindKeys = array_keys($binds); + + $this->assertSame( + 'FOR characterDoc IN characters FILTER `characterDoc`.`alive` == @' . $bindKeys[0] + . ' and not ( `characterDoc`.`surname` == @' . $bindKeys[1] + . ' or `characterDoc`.`age` < @' . $bindKeys[2] + . ') RETURN characterDoc', + $query->toSql(), + ); +}); + +test('whereNot query results', function () { + $results = \DB::table('characters') + ->where('alive', true) + ->whereNot(function ($query) { + $query->where('surname', 'Lannister') + ->orWhere('age', '<', 20); + })->get(); + + expect($results->count())->toBe(3); +}); + +test('basic orWhereNot', function () { + $builder = getBuilder($this->connection); + $builder->select('*')->from('characters')->where('alive', true)->orWhereNot('surname', 'Lannister'); + + $this->assertSame( + 'FOR characterDoc IN characters FILTER `characterDoc`.`alive` == @' + . $builder->getQueryId() + . '_where_1 or not `characterDoc`.`surname` == @' + . $builder->getQueryId() + . '_where_2 RETURN characterDoc', + $builder->toSql(), + ); +}); + + +test('orWhereNot query results', function () { + $results = \DB::table('characters') + ->where('alive', true) + ->orWhereNot('surname', 'Lannister') + ->get(); + + expect($results->count())->toBe(27); +}); + +test('nest whereNot & orWhereNot', function () { + $builder = \DB::table('characters') + ->where('alive', true) + ->where(function ($query) { + $query->whereNot('surname', 'Lannister') + ->orWhereNot('age', '<', 20); + }); + + $results = $builder->get(); + + expect($results->count())->toBe(27); +}); + +test('whereLike', function () { + $query = \DB::table('houses') + ->whereLike('en.coat-of-arms', '%dragon%'); + + $binds = $query->getBindings(); + $bindKeys = array_keys($binds); + + $this->assertSame( + 'FOR houseDoc IN houses FILTER LOWER(`houseDoc`.`en`.`coat-of-arms`) LIKE LOWER(@' . $bindKeys[0] + . ') RETURN houseDoc', + $query->toSql(), + ); + + $results = $query->get(); + expect($results->count())->toBe(1); + expect(($results->first())->name)->toBe('Targaryen'); +}); + +test('orWhereLike', function () { + $query = \DB::table('houses') + ->where('name', 'Stark') + ->orWhereLike('en.coat-of-arms', '%dragon%'); + + $binds = $query->getBindings(); + $bindKeys = array_keys($binds); + + $this->assertSame( + 'FOR houseDoc IN houses FILTER `houseDoc`.`name` == @' . $bindKeys[0] + . ' or LOWER(`houseDoc`.`en`.`coat-of-arms`) LIKE LOWER(@' . $bindKeys[1] + . ') RETURN houseDoc', + $query->toSql(), + ); + + $results = $query->get(); + expect($results->count())->toBe(2); + expect(($results->first())->name)->toBe('Stark'); +}); + +test('whereNotLike', function () { + $query = \DB::table('houses') + ->whereNotLike('en.coat-of-arms', '%dragon%'); + + $binds = $query->getBindings(); + $bindKeys = array_keys($binds); + + $this->assertSame( + 'FOR houseDoc IN houses FILTER NOT (LOWER(`houseDoc`.`en`.`coat-of-arms`) LIKE LOWER(@' . $bindKeys[0] + . ')) RETURN houseDoc', + $query->toSql(), + ); + + $results = $query->get(); + expect($results->count())->toBe(2); + expect(($results->first())->name)->toBe('Lannister'); +}); + +test('orWhereNotLike', function () { + $query = \DB::table('houses') + ->where('name', 'Stark') + ->orWhereNotLike('en.coat-of-arms', '%dragon%'); + + $binds = $query->getBindings(); + $bindKeys = array_keys($binds); + + $this->assertSame( + 'FOR houseDoc IN houses FILTER `houseDoc`.`name` == @' . $bindKeys[0] + . ' or NOT (LOWER(`houseDoc`.`en`.`coat-of-arms`) LIKE LOWER(@' . $bindKeys[1] + . ')) RETURN houseDoc', + $query->toSql(), + ); + + $results = $query->get(); + expect($results->count())->toBe(2); + expect(($results->first())->name)->toBe('Lannister'); +}); diff --git a/tests/QueryExceptionTest.php b/tests/QueryExceptionTest.php index a185917..c20489c 100644 --- a/tests/QueryExceptionTest.php +++ b/tests/QueryExceptionTest.php @@ -17,14 +17,8 @@ }); test('query exception has correct message', function () { - $this->expectExceptionMessage( - "400 - AQL: syntax error, unexpected identifier near 'this is not AQL' at position 1:1 (while parsing)" - . " (Connection: arangodb,AQL: this is not AQL - Bindings: array (\n" - . " 'testBind' => 'test',\n))", - ); - DB::execute('this is not AQL', ['testBind' => 'test']); -}); +})->throws(QueryException::class, 'this is not AQL'); test('query exception without binds', function () { expect(fn() => DB::execute("this is not AQL", []))->toThrow(QueryException::class); diff --git a/tests/Schema/AnalyzerTest.php b/tests/Schema/AnalyzerTest.php index f10a568..d96149c 100644 --- a/tests/Schema/AnalyzerTest.php +++ b/tests/Schema/AnalyzerTest.php @@ -7,9 +7,10 @@ test('createAnalyzer', function () { $schemaManager = $this->connection->getArangoClient()->schema(); if (!$schemaManager->hasAnalyzer('myAnalyzer')) { - Schema::createAnalyzer('myAnalyzer', [ - 'type' => 'identity', - ]); + Schema::createAnalyzer( + 'myAnalyzer', + 'identity', + ); } $analyzer = $schemaManager->getAnalyzer('myAnalyzer'); @@ -18,25 +19,23 @@ $schemaManager->deleteAnalyzer('myAnalyzer'); }); -test('getAllAnalyzers', function () { +test('getAnalyzers', function () { $schemaManager = $this->connection->getArangoClient()->schema(); + $initialAnalyzers = $schemaManager->getAnalyzers(); - $analyzers = Schema::getAllAnalyzers(); + $analyzers = Schema::getAnalyzers(); - expect($analyzers)->toHaveCount(13); + $endAnalyzers = $schemaManager->getAnalyzers(); + expect(count($initialAnalyzers))->toBe(count($endAnalyzers)); }); test('replaceAnalyzer', function () { $schemaManager = $this->connection->getArangoClient()->schema(); if (!$schemaManager->hasAnalyzer('myAnalyzer')) { - Schema::createAnalyzer('myAnalyzer', [ - 'type' => 'identity', - ]); + Schema::createAnalyzer('myAnalyzer', 'identity'); } - Schema::replaceAnalyzer('myAnalyzer', [ - 'type' => 'identity', - ]); + Schema::replaceAnalyzer('myAnalyzer', 'identity'); $schemaManager->deleteAnalyzer('myAnalyzer'); }); @@ -44,9 +43,7 @@ test('dropAnalyzer', function () { $schemaManager = $this->connection->getArangoClient()->schema(); if (!$schemaManager->hasAnalyzer('myAnalyzer')) { - Schema::createAnalyzer('myAnalyzer', [ - 'type' => 'identity', - ]); + Schema::createAnalyzer('myAnalyzer', 'identity'); } Schema::dropAnalyzer('myAnalyzer'); @@ -56,9 +53,7 @@ test('dropAnalyzerIfExists true', function () { $schemaManager = $this->connection->getArangoClient()->schema(); if (!$schemaManager->hasAnalyzer('myAnalyzer')) { - Schema::createAnalyzer('myAnalyzer', [ - 'type' => 'identity', - ]); + Schema::createAnalyzer('myAnalyzer', 'identity'); } Schema::dropAnalyzerIfExists('myAnalyzer'); @@ -70,3 +65,21 @@ Schema::dropAnalyzerIfExists('none-existing-analyzer'); }); + +test('dropAllAnalyzers', function () { + $schemaManager = $this->connection->getArangoClient()->schema(); + + $initialAnalyzers = Schema::getAnalyzers(); + + Schema::createAnalyzer('myAnalyzer1', 'identity'); + Schema::createAnalyzer('myAnalyzer2', 'identity'); + + $totalAnalyzers = Schema::getAnalyzers(); + + Schema::dropAllAnalyzers(); + + $endAnalyzers = Schema::getAnalyzers(); + + expect(count($initialAnalyzers))->toBe(count($endAnalyzers)); + expect(count($initialAnalyzers))->toBe(count($totalAnalyzers) - 2); +}); diff --git a/tests/Schema/ColumnTest.php b/tests/Schema/ColumnTest.php index 1ae2f5f..6bcde80 100644 --- a/tests/Schema/ColumnTest.php +++ b/tests/Schema/ColumnTest.php @@ -4,7 +4,7 @@ use Mockery as M; beforeEach(function () { - $this->grammar = new Grammar(); + $this->grammar = new Grammar($this->connection); }); afterEach(function () { diff --git a/tests/Schema/GraphTest.php b/tests/Schema/GraphTest.php new file mode 100644 index 0000000..9358f00 --- /dev/null +++ b/tests/Schema/GraphTest.php @@ -0,0 +1,98 @@ + [ + [ + 'collection' => 'children', + 'from' => ['characters'], + 'to' => ['characters'], + ], + ], + ], + true, + ); +} + +test('graph CRUD', function () { + $schemaManager = $this->connection->getArangoClient()->schema(); + if (!$schemaManager->hasGraph('myGraph')) { + createGraph(); + } + + $graphExists = $schemaManager->hasGraph('myGraph'); + expect($graphExists)->toBeTrue(); + + $graph = $schemaManager->getGraph('myGraph'); + expect($graph->name)->toEqual('myGraph'); + + $schemaManager->deleteGraph('myGraph'); + $graphExists = $schemaManager->hasGraph('myGraph'); + expect($graphExists)->toBeFalse(); +}); + +test('getGraphs', function () { + $schemaManager = $this->connection->getArangoClient()->schema(); + + $graphs = Schema::getGraphs(); + expect($graphs)->toHaveCount(0); + + if (!$schemaManager->hasGraph('myGraph')) { + createGraph(); + } + + $graphs = Schema::getGraphs(); + expect($graphs)->toHaveCount(1); +}); + +test('dropGraph', function () { + $schemaManager = $this->connection->getArangoClient()->schema(); + if (!$schemaManager->hasGraph('myGraph')) { + createGraph(); + } + + Schema::dropGraph('myGraph'); + + $schemaManager->getGraph('myGraph'); +})->throws(ArangoException::class); + +test('dropGraphIfExists true', function () { + $schemaManager = $this->connection->getArangoClient()->schema(); + if (!$schemaManager->hasAnalyzer('myGraph')) { + createGraph(); + } + Schema::dropGraphIfExists('myGraph'); + + $schemaManager->getGraph('myGraph'); +})->throws(ArangoException::class); + +test('dropGraphIfExists false', function () { + $schemaManager = $this->connection->getArangoClient()->schema(); + + Schema::dropGraphIfExists('none-existing-graph'); +}); + +test('dropAllGraphs', function () { + $schemaManager = $this->connection->getArangoClient()->schema(); + + $initialGraphs = Schema::getGraphs(); + + Schema::createGraph('myGraph1'); + Schema::createGraph('myGraph2'); + + $totalGraphs = Schema::getGraphs(); + + Schema::dropAllGraphs(); + + $endGraphs = Schema::getGraphs(); + + expect(count($initialGraphs))->toBe(count($endGraphs)); + expect(count($initialGraphs))->toBe(count($totalGraphs) - 2); +}); diff --git a/tests/Schema/IndexTest.php b/tests/Schema/IndexTest.php index 2ff8b7f..843b04e 100644 --- a/tests/Schema/IndexTest.php +++ b/tests/Schema/IndexTest.php @@ -33,6 +33,26 @@ expect($searchResult)->toBeFalse(); }); + +test('Schema::hasIndex', function () { + Schema::table('characters', function (Blueprint $table) { + $table->index(['name']); + }); + $indexName = 'characters_name_persistent'; + + expect(Schema::hasIndex('characters', $indexName))->toBeTrue(); + expect(Schema::hasIndex('characters', 'notAnIndex'))->toBeFalse(); +}); + +test('Schema::hasIndex by columns', function () { + Schema::table('characters', function (Blueprint $table) { + $table->index(['name']); + }); + + expect(Schema::hasIndex('characters', ['name']))->toBeTrue(); + expect(Schema::hasIndex('characters', ['name', 'lastName']))->toBeFalse(); +}); + test('index names only contains alpha numeric characters', function () { Schema::table('characters', function (Blueprint $table) { $indexName = $table->createIndexName('persistent', ['addresses[*]']); @@ -97,6 +117,98 @@ }); }); +test('invertedIndex with field properties', function () { + Schema::table('characters', function (Blueprint $table) { + $table->invertedIndex( + [ + 'name', + [ + 'name' => 'surname', + 'analyzer' => "text_en", + 'searchField' => true, + 'includeAllFields' => true, + ], + 'age', + ], + ); + }); + + $expectedName = 'characters_name_surname_age_inverted'; + + $index = $this->schemaManager->getIndexByName('characters', $expectedName); + + expect($index->fields)->tobeArray(); + expect($index->fields)->toHaveCount(3); + + expect($index->fields[1]->analyzer)->toBe('text_en'); + + Schema::table('characters', function (Blueprint $table) use ($index) { + $table->dropInvertedIndex($index->name); + }); +}); + +test('multiDimensionalIndex && dropMultiDimensionalIndex', function () { + $this->skipTestOn('arangodb', '<', '3.12'); + + $name = 'events_timeline_mdi'; + + Schema::table('events', function (Blueprint $table) use ($name) { + $table->multiDimensionalIndex( + columns: [ + 'timeline.starts_at', + 'timeline.ends_at', + ], + name: $name, + indexOptions: [ + 'fieldValueTypes' => 'double', + ], + ); + }); + + $index = $this->schemaManager->getIndexByName('events', $name); + + expect($index->name)->toEqual($name); + expect($index->type)->toEqual('mdi'); + + Schema::table('events', function (Blueprint $table) use ($name) { + $table->dropMultiDimensionalIndex($name); + }); +}); + +test('Prefixed multiDimensionalIndex', function () { + $this->skipTestOn('arangodb', '<', '3.12'); + + $name = 'events_timeline_mdi_prefixed'; + + Schema::table('events', function (Blueprint $table) use ($name) { + $table->multiDimensionalIndex( + columns: [ + 'timeline.starts_at', + 'timeline.ends_at', + ], + name: $name, + indexOptions: [ + 'fieldValueTypes' => 'double', + 'prefixFields' => [ + 'age', + 'type', + ], + ], + type: 'mdi-prefixed', + ); + }); + + $index = $this->schemaManager->getIndexByName('events', $name); + + expect($index->name)->toEqual($name); + expect($index->type)->toEqual('mdi-prefixed'); + + Schema::table('events', function (Blueprint $table) use ($name) { + $table->dropMultiDimensionalIndex($name); + }); +}); + + test('persistentIndex', function () { Schema::table('characters', function (Blueprint $table) { $table->persistentIndex(['name']); diff --git a/tests/Schema/SchemaBuilderTest.php b/tests/Schema/SchemaBuilderTest.php index 12796f8..2ba9a01 100644 --- a/tests/Schema/SchemaBuilderTest.php +++ b/tests/Schema/SchemaBuilderTest.php @@ -28,7 +28,7 @@ try { Schema::hasTable('dummy'); - } catch(QueryException $e) { + } catch (QueryException $e) { expect($e)->toBeInstanceOf(QueryException::class); } config()->set('database.connections.arangodb.database', $oldDatabase); @@ -45,12 +45,12 @@ }); test('drop all tables', function () { - $initialTables = Schema::getAllTables(); + $initialTables = Schema::getTables(); Schema::dropAllTables(); - $tables = Schema::getAllTables(); + $tables = Schema::getTables(); - expect(count($initialTables))->toEqual(15); + expect(count($initialTables))->toEqual($this->tableCount); expect(count($tables))->toEqual(0); $this->artisan('migrate:install')->assertExitCode(0); @@ -85,7 +85,7 @@ } $view = Schema::getView('search'); - expect($view->name)->toEqual('search'); + expect($view['name'])->toEqual('search'); $schemaManager->deleteView('search'); }); @@ -102,14 +102,14 @@ Schema::createView('search', []); } - $views = Schema::getAllViews(); + $views = Schema::getViews(); expect($views)->toHaveCount(5); - expect($views[0]->name)->toBe('house_search_alias_view'); - expect($views[1]->name)->toBe('house_view'); - expect($views[2]->name)->toBe('pages'); - expect($views[3]->name)->toBe('products'); - expect($views[4]->name)->toBe('search'); + expect($views[0]['name'])->toBe('house_search_alias_view'); + expect($views[1]['name'])->toBe('house_view'); + expect($views[2]['name'])->toBe('pages'); + expect($views[3]['name'])->toBe('products'); + expect($views[4]['name'])->toBe('search'); $schemaManager->deleteView('search'); $schemaManager->deleteView('pages'); @@ -217,9 +217,7 @@ test('createAnalyzer', function () { $schemaManager = $this->connection->getArangoClient()->schema(); if (!$schemaManager->hasAnalyzer('myAnalyzer')) { - Schema::createAnalyzer('myAnalyzer', [ - 'type' => 'identity', - ]); + Schema::createAnalyzer('myAnalyzer', 'identity'); } $analyzer = $schemaManager->getAnalyzer('myAnalyzer'); @@ -228,25 +226,22 @@ $schemaManager->deleteAnalyzer('myAnalyzer'); }); -test('getAllAnalyzers', function () { +test('getAnalyzers', function () { $schemaManager = $this->connection->getArangoClient()->schema(); + $initialAnalyzers = $schemaManager->getAnalyzers(); - $analyzers = Schema::getAllAnalyzers(); + $analyzers = Schema::getAnalyzers(); - expect($analyzers)->toHaveCount(13); + expect(count($initialAnalyzers))->toBe(count($analyzers)); }); test('replaceAnalyzer', function () { $schemaManager = $this->connection->getArangoClient()->schema(); if (!$schemaManager->hasAnalyzer('myAnalyzer')) { - Schema::createAnalyzer('myAnalyzer', [ - 'type' => 'identity', - ]); + Schema::createAnalyzer('myAnalyzer', 'identity'); } - Schema::replaceAnalyzer('myAnalyzer', [ - 'type' => 'identity', - ]); + Schema::replaceAnalyzer('myAnalyzer', 'identity'); $schemaManager->deleteAnalyzer('myAnalyzer'); }); @@ -254,9 +249,7 @@ test('dropAnalyzer', function () { $schemaManager = $this->connection->getArangoClient()->schema(); if (!$schemaManager->hasAnalyzer('myAnalyzer')) { - Schema::createAnalyzer('myAnalyzer', [ - 'type' => 'identity', - ]); + Schema::createAnalyzer('myAnalyzer', 'identity'); } Schema::dropAnalyzer('myAnalyzer'); @@ -266,9 +259,7 @@ test('dropAnalyzerIfExists true', function () { $schemaManager = $this->connection->getArangoClient()->schema(); if (!$schemaManager->hasAnalyzer('myAnalyzer')) { - Schema::createAnalyzer('myAnalyzer', [ - 'type' => 'identity', - ]); + Schema::createAnalyzer('myAnalyzer', 'identity'); } Schema::dropAnalyzerIfExists('myAnalyzer'); diff --git a/tests/Schema/TableKeyTest.php b/tests/Schema/TableKeyTest.php new file mode 100644 index 0000000..4248b0a --- /dev/null +++ b/tests/Schema/TableKeyTest.php @@ -0,0 +1,128 @@ +getSchemaBuilder(); + + $schema->create('white_walkers', function (Blueprint $table) use (& $creating) {}); + + $schemaManager = $this->connection->getArangoClient()->schema(); + + $collectionProperties = $schemaManager->getCollectionProperties('white_walkers'); + + expect($collectionProperties->keyOptions->type)->toBe('traditional'); + + Schema::drop('white_walkers'); +}); + +test('creating table with different default key generator', function () { + Config::set('arangodb.schema.keyOptions.type', 'padded'); + $schema = DB::connection()->getSchemaBuilder(); + + $schema->create('white_walkers', function (Blueprint $table) use (& $creating) {}); + + $schemaManager = $this->connection->getArangoClient()->schema(); + + $collectionProperties = $schemaManager->getCollectionProperties('white_walkers'); + + expect($collectionProperties->keyOptions->type)->toBe('padded'); + + Schema::drop('white_walkers'); +}); + +test('creating table with autoincrement key generator', function () { + Config::set('arangodb.schema.key_handling.use_traditional_over_autoincrement', false); + + $schema = DB::connection()->getSchemaBuilder(); + + $schema->create('white_walkers', function (Blueprint $table) use (& $creating) { + $table->increments('id'); + }); + + $schemaManager = $this->connection->getArangoClient()->schema(); + + $collectionProperties = $schemaManager->getCollectionProperties('white_walkers'); + + expect($collectionProperties->keyOptions->type)->toBe('autoincrement'); + + Schema::drop('white_walkers'); + Config::set('arangodb.schema.key_handling.use_traditional_over_autoincrement', true); +}); + +test('creating table with autoIncrement offset', function () { + Config::set('arangodb.schema.key_handling.use_traditional_over_autoincrement', false); + + $schema = DB::connection()->getSchemaBuilder(); + + $schema->create('white_walkers', function (Blueprint $table) use (& $creating) { + $table->string('id')->autoIncrement()->from(5); + }); + + $schemaManager = $this->connection->getArangoClient()->schema(); + + $collectionProperties = $schemaManager->getCollectionProperties('white_walkers'); + + expect($collectionProperties->keyOptions->type)->toBe('autoincrement'); + expect($collectionProperties->keyOptions->offset)->toBe(5); + + Schema::drop('white_walkers'); +}); + +test('create table with uuid key generator', function () { + $schema = DB::connection()->getSchemaBuilder(); + + $schema->create('white_walkers', function (Blueprint $table) use (& $creating) { + $table->uuid('id'); + }); + + $schemaManager = $this->connection->getArangoClient()->schema(); + + $collectionProperties = $schemaManager->getCollectionProperties('white_walkers'); + + expect($collectionProperties->keyOptions->type)->toBe('uuid'); + + Schema::drop('white_walkers'); +}); + +test('table options override column key generator', function () { + $schema = DB::connection()->getSchemaBuilder(); + + $schema->create('white_walkers', function (Blueprint $table) use (& $creating) { + $table->uuid('id'); + }, [ + 'keyOptions' => [ + 'type' => 'padded', + ], + ]); + + $schemaManager = $this->connection->getArangoClient()->schema(); + + $collectionProperties = $schemaManager->getCollectionProperties('white_walkers'); + + expect($collectionProperties->keyOptions->type)->toBe('padded'); + + Schema::drop('white_walkers'); +}); + +test('table options override default key generator', function () { + $schema = DB::connection()->getSchemaBuilder(); + + $schema->create('white_walkers', function (Blueprint $table) use (& $creating) {}, [ + 'keyOptions' => [ + 'type' => 'padded', + 'allowUserKeys' => false, + ], + ]); + + $schemaManager = $this->connection->getArangoClient()->schema(); + + $collectionProperties = $schemaManager->getCollectionProperties('white_walkers'); + + expect($collectionProperties->keyOptions->type)->toBe('padded'); + expect($collectionProperties->keyOptions->allowUserKeys)->toBeFalse(); + + Schema::drop('white_walkers'); +}); diff --git a/tests/Schema/TableTest.php b/tests/Schema/TableTest.php index 694db2e..d01b32b 100644 --- a/tests/Schema/TableTest.php +++ b/tests/Schema/TableTest.php @@ -1,72 +1,20 @@ getSchemaBuilder(); - $schema->create('white_walkers', function (Blueprint $table) use (& $creating) { - $table->increments('id'); - }); + $schema->create('white_walkers', function (Blueprint $table) use (& $creating) {}); $schemaManager = $this->connection->getArangoClient()->schema(); $collectionProperties = $schemaManager->getCollectionProperties('white_walkers'); - expect($collectionProperties->keyOptions->type)->toBe('autoincrement'); - - Schema::drop('white_walkers'); -}); - -test('creating table with autoIncrement', function () { - $schema = DB::connection()->getSchemaBuilder(); - - $schema->create('white_walkers', function (Blueprint $table) use (& $creating) { - $table->string('id')->autoIncrement(); - }); - - $schemaManager = $this->connection->getArangoClient()->schema(); - - $collectionProperties = $schemaManager->getCollectionProperties('white_walkers'); - - expect($collectionProperties->keyOptions->type)->toBe('autoincrement'); - - Schema::drop('white_walkers'); -}); -test('creating table with autoIncrement offset', function () { - $schema = DB::connection()->getSchemaBuilder(); - - $schema->create('white_walkers', function (Blueprint $table) use (& $creating) { - $table->string('id')->autoIncrement()->from(5); - }); - - $schemaManager = $this->connection->getArangoClient()->schema(); - - $collectionProperties = $schemaManager->getCollectionProperties('white_walkers'); - - expect($collectionProperties->keyOptions->type)->toBe('autoincrement'); - Schema::drop('white_walkers'); }); -test('create table with uuid key generator', function () { - $schema = DB::connection()->getSchemaBuilder(); - - $schema->create('white_walkers', function (Blueprint $table) use (& $creating) { - $table->uuid('id'); - }); - - $schemaManager = $this->connection->getArangoClient()->schema(); - - $collectionProperties = $schemaManager->getCollectionProperties('white_walkers'); - - expect($collectionProperties->keyOptions->type)->toBe('uuid'); - - Schema::drop('white_walkers'); -}); - - test('hasTable', function () { expect(Schema::hasTable('locations'))->toBeTrue(); expect(Schema::hasTable('dummy'))->toBeFalse(); @@ -95,13 +43,13 @@ }); test('dropAllTables', function () { - $initialTables = Schema::getAllTables(); + $initialTables = Schema::getTables(); Schema::dropAllTables(); - $tables = Schema::getAllTables(); + $tables = Schema::getTables(); - expect(count($initialTables))->toEqual(15); + expect(count($initialTables))->toEqual($this->tableCount); expect(count($tables))->toEqual(0); refreshDatabase(); diff --git a/tests/Schema/ViewTest.php b/tests/Schema/ViewTest.php index 806fe80..8cdce64 100644 --- a/tests/Schema/ViewTest.php +++ b/tests/Schema/ViewTest.php @@ -23,12 +23,12 @@ } $view = Schema::getView('search'); - expect($view->name)->toEqual('search'); + expect($view['name'])->toEqual('search'); $schemaManager->deleteView('search'); }); -test('getAllViews', function () { +test('getViews', function () { $schemaManager = $this->connection->getArangoClient()->schema(); if (!$schemaManager->hasView('pages')) { Schema::createView('pages', []); @@ -40,14 +40,14 @@ Schema::createView('search', []); } - $views = Schema::getAllViews(); + $views = Schema::getViews(); expect($views)->toHaveCount(5); - expect($views[0]->name)->toBe('house_search_alias_view'); - expect($views[1]->name)->toBe('house_view'); - expect($views[2]->name)->toBe('pages'); - expect($views[3]->name)->toBe('products'); - expect($views[4]->name)->toBe('search'); + expect($views[0]['name'])->toBe('house_search_alias_view'); + expect($views[1]['name'])->toBe('house_view'); + expect($views[2]['name'])->toBe('pages'); + expect($views[3]['name'])->toBe('products'); + expect($views[4]['name'])->toBe('search'); $schemaManager->deleteView('search'); $schemaManager->deleteView('pages'); diff --git a/tests/TestCase.php b/tests/TestCase.php index 25219c2..a351dde 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -26,7 +26,7 @@ class TestCase extends \Orchestra\Testbench\TestCase protected ?ConnectionInterface $connection; - protected bool $dropViews = true; + protected bool $dropAll = true; protected bool $realPath = true; @@ -36,6 +36,8 @@ class TestCase extends \Orchestra\Testbench\TestCase protected string $seeder = DatabaseSeeder::class; + protected int $tableCount = 16; + /** * The base URL to use while testing the application. * @@ -206,7 +208,7 @@ protected function defineEnvironment($app) public function clearDatabase() { $collections = $this->schemaManager->getCollections(true); - foreach($collections as $collection) { + foreach ($collections as $collection) { $this->schemaManager->deleteCollection($collection->name); } } @@ -225,6 +227,10 @@ protected function skipTestOn(string $software, string $operator = '<', string $ $currentVersion = getenv(strtoupper($software . '_VERSION')); } + if (!$currentVersion) { + return; + } + if (version_compare($currentVersion, $version, $operator)) { $this->markTestSkipped('This test does not support ' . ucfirst($software) . ' versions ' . $operator . ' ' . $version); } diff --git a/tests/Testing/DatabaseTruncationTest.php b/tests/Testing/DatabaseTruncationTest.php index 1de61c3..de1cf2d 100644 --- a/tests/Testing/DatabaseTruncationTest.php +++ b/tests/Testing/DatabaseTruncationTest.php @@ -9,12 +9,11 @@ uses(DatabaseTruncation::class); test('Ensure all tables are present', function () { - $tables = Schema::getAllTables(); + $tables = Schema::getTables(); - expect(count($tables))->toEqual(15); + expect(count($tables))->toEqual($this->tableCount); }); - test('Ensure all characters are present', function () { $characters = Character::all(); diff --git a/tests/Testing/InteractsWithDatabaseTest.php b/tests/Testing/InteractsWithDatabaseTest.php index a91294f..d32459a 100644 --- a/tests/Testing/InteractsWithDatabaseTest.php +++ b/tests/Testing/InteractsWithDatabaseTest.php @@ -72,6 +72,7 @@ test('assert model exists', function () { $ned = Character::find('NedStark'); + $this->assertModelExists($ned); });