diff --git a/config/system.php b/config/system.php index 05ffa7e48f..4f4911b566 100644 --- a/config/system.php +++ b/config/system.php @@ -165,4 +165,17 @@ 'row_id_handle' => 'id', + /* + |-------------------------------------------------------------------------- + | Fake SQL Queries + |-------------------------------------------------------------------------- + | + | When enabled, Statamic's query builders will emit events that appear + | the same way as any other query. This can be useful for debugging. + | The generated SQL statements are approximations and not exact. + | + */ + + 'fake_sql_queries' => config('app.debug'), + ]; diff --git a/src/Assets/QueryBuilder.php b/src/Assets/QueryBuilder.php index 40c8c83b0e..247d6d1064 100644 --- a/src/Assets/QueryBuilder.php +++ b/src/Assets/QueryBuilder.php @@ -7,6 +7,7 @@ use Statamic\Contracts\Assets\QueryBuilder as Contract; use Statamic\Facades; use Statamic\Stache\Query\Builder as BaseQueryBuilder; +use Statamic\Support\Arr; class QueryBuilder extends BaseQueryBuilder implements Contract { @@ -119,4 +120,24 @@ protected function getWhereColumnKeyValuesByIndex($column) return $items; } + + public function getTableNameForFakeQuery(): string + { + return 'assets'; + } + + public function prepareForFakeQuery(): array + { + $data = parent::prepareForFakeQuery(); + + $data['wheres'] = Arr::prepend($data['wheres'], [ + 'type' => 'Basic', + 'column' => 'container', + 'operator' => '=', + 'value' => $this->getContainer()->handle(), + 'boolean' => 'and', + ]); + + return $data; + } } diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 5a013e3b1c..bb93bd9d14 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -12,9 +12,12 @@ use Statamic\Contracts\Query\Builder as Contract; use Statamic\Extensions\Pagination\LengthAwarePaginator; use Statamic\Facades\Pattern; +use Statamic\Query\Concerns\FakesQueries; abstract class Builder implements Contract { + use FakesQueries; + protected $columns; protected $limit; protected $offset = 0; @@ -570,6 +573,21 @@ abstract public function count(); abstract public function get($columns = ['*']); + protected function onceWithColumns($columns, $callback) + { + $original = $this->columns; + + if (is_null($original)) { + $this->columns = $columns; + } + + $result = $callback(); + + $this->columns = $original; + + return $result; + } + abstract public function pluck($column, $key = null); public function when($value, $callback, $default = null) diff --git a/src/Query/Concerns/FakesQueries.php b/src/Query/Concerns/FakesQueries.php new file mode 100644 index 0000000000..26c0dc5bee --- /dev/null +++ b/src/Query/Concerns/FakesQueries.php @@ -0,0 +1,42 @@ +dump(), + $sql->bindings()->all(), + $time, + $sql->connection() + )); + + return $value; + } + + public function prepareForFakeQuery(): array + { + return [ + 'wheres' => $this->wheres, + 'columns' => $this->columns, + 'orderBys' => $this->orderBys, + 'limit' => $this->limit, + 'offset' => $this->offset, + ]; + } +} diff --git a/src/Query/Dumper/Concerns/DumpsQueryParts.php b/src/Query/Dumper/Concerns/DumpsQueryParts.php new file mode 100644 index 0000000000..dbc412f5a7 --- /dev/null +++ b/src/Query/Dumper/Concerns/DumpsQueryParts.php @@ -0,0 +1,45 @@ +columns); + } + + protected function dumpLimits(): string + { + if (! $this->limit) { + return ''; + } + + $limit = ' limit '.$this->limit; + + if ($this->offset) { + $limit .= ' offset '.$this->offset; + } + + return $limit; + } + + protected function dumpOrderBys(): string + { + if (count($this->orderBys) === 0) { + return ''; + } + + $orders = []; + + foreach ($this->orderBys as $orderBy) { + if (! $orderBy->sort) { + continue; + } + + $orders[] = $orderBy->sort.' '.$orderBy->direction; + } + + return ' order by '.implode(', ', $orders); + } +} diff --git a/src/Query/Dumper/Concerns/DumpsQueryValues.php b/src/Query/Dumper/Concerns/DumpsQueryValues.php new file mode 100644 index 0000000000..1371bbfc6a --- /dev/null +++ b/src/Query/Dumper/Concerns/DumpsQueryValues.php @@ -0,0 +1,24 @@ +map(function ($value) { + return $this->dumpQueryValue($value); + })->implode(', '); + } + + protected function dumpQueryValue($value): string + { + $this->bindings[] = $value; + + return '?'; + } +} diff --git a/src/Query/Dumper/Concerns/DumpsWheres.php b/src/Query/Dumper/Concerns/DumpsWheres.php new file mode 100644 index 0000000000..18d9d25ef8 --- /dev/null +++ b/src/Query/Dumper/Concerns/DumpsWheres.php @@ -0,0 +1,160 @@ +dumpQueryValue($where['value'] ?? null); + } + + protected function dumpArrayWhere($keyword, $where): string + { + return $where['column'].' '.$keyword.' ('.$this->dumpQueryArrayValues($where['values'] ?? []).')'; + } + + protected function dumpSimpleOperatorWhere($where): string + { + return $where['column'].' '.$where['operator'].$this->dumpQueryValue($where['value'] ?? null); + } + + protected function dumpIn($where): string + { + return $this->dumpArrayWhere('in', $where); + } + + protected function dumpNotIn($where): string + { + return $this->dumpArrayWhere('not in', $where); + } + + protected function dumpNull($where): string + { + return $where['column'].' is null'; + } + + protected function dumpNotNull($where): string + { + return $where['column'].' is not null'; + } + + protected function dumpDatePartMethod($datePart, $where): string + { + return 'DATEPART('.$datePart.', '.$where['column'].') = '.$this->dumpQueryValue($where['value'] ?? null); + } + + protected function dumpMonth($where): string + { + return $this->dumpDatePartMethod('MONTH', $where); + } + + protected function dumpDay($where): string + { + return $this->dumpDatePartMethod('DAY', $where); + } + + protected function dumpYear($where): string + { + return $this->dumpDatePartMethod('YEAR', $where); + } + + protected function dumpTime($where): string + { + return $this->dumpDatePartMethod('TIMESTAMP', $where); + } + + protected function dumpBetween($where): string + { + $valueOne = $this->dumpQueryValue($where['values'][0] ?? null); + $valueTwo = $this->dumpQueryValue($where['values'][1] ?? null); + $column = $where['column']; + + return $column.' between '.$valueOne.' and '.$valueTwo; + } + + protected function dumpNotBetween($where): string + { + $valueOne = $this->dumpQueryValue($where['values'][0] ?? null); + $valueTwo = $this->dumpQueryValue($where['values'][1] ?? null); + $column = $where['column']; + + return $column.' not between '.$valueOne.' and '.$valueTwo; + } + + protected function dumpColumn($where): string + { + return $where['column'].' = '.$where['value']; + } + + protected function dumpNested($where): string + { + $query = $where['query'] ?? null; + + $sql = (new Dumper($query))->withBindings($this->bindings)->dumpWheres(); + + return "($sql)"; + } + + protected function dumpDate($where) + { + return $this->dumpSimpleOperatorWhere($where); + } + + protected function dumpJsonMethod($where): string + { + $jsonMethod = strtoupper(Str::snake($where['type'])); + + if (isset($where['values'])) { + $valueString = $this->dumpQueryArrayValues($where['values']); + } else { + $valueString = $this->dumpQueryValue($where['value'] ?? null); + } + + return $jsonMethod.'('.$where['column'].', '.$valueString.')'; + } + + protected function dumpWhere($isFirst, $where): string + { + $dumpedWhere = ''; + + if (! $isFirst) { + $dumpedWhere = $where['boolean'].' '; + } + + $type = $where['type']; + + if (Str::startsWith($type, 'Json')) { + $dumpedWhere .= $this->dumpJsonMethod($where); + } else { + $whereMethod = 'dump'.ucfirst($type); + + if (method_exists($this, $whereMethod)) { + $dumpedWhere .= $this->{$whereMethod}($where); + } else { + // Fail-safe to dump "something". + $dumpedWhere .= strtoupper($type); + } + } + + return $dumpedWhere; + } + + protected function dumpWheres(): string + { + if (count($this->wheres) === 0) { + return ''; + } + + $parts = []; + + for ($i = 0; $i < count($this->wheres); $i++) { + $parts[] = $this->dumpWhere($i === 0, $this->wheres[$i]); + } + + return implode(' ', $parts); + } +} diff --git a/src/Query/Dumper/Dumper.php b/src/Query/Dumper/Dumper.php new file mode 100644 index 0000000000..e1432ab46d --- /dev/null +++ b/src/Query/Dumper/Dumper.php @@ -0,0 +1,92 @@ +prepareForFakeQuery(); + $this->table = $this->getTableName($query); + $this->wheres = $data['wheres']; + $this->columns = $data['columns'] ?? ['*']; + $this->orderBys = $data['orderBys']; + $this->limit = $data['limit']; + $this->offset = $data['offset']; + $this->bindings = collect(); + } + + public function bindings(): Collection + { + return $this->bindings; + } + + public function withBindings(Collection $bindings): self + { + $this->bindings = $bindings; + + return $this; + } + + public function connection() + { + if (! app()->bound($key = 'fake-query-connection')) { + app()->instance($key, DB::connectUsing('fake', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ])); + } + + return app($key); + } + + public function dump(): string + { + $query = 'select '.$this->dumpColumns().' from '.$this->table; + + if (! empty($this->wheres)) { + $query .= ' where '.$this->dumpWheres(); + } + + $query .= $this->dumpLimits(); + $query .= $this->dumpOrderBys(); + + return $query; + } + + private function getTableName(Builder $class): string + { + if (method_exists($class, 'getTableNameForFakeQuery')) { + return $class->getTableNameForFakeQuery(); + } + + if ($class instanceof StacheQueryBuilder) { + return Str::of(class_basename($class)) + ->before('QueryBuilder') + ->lower() + ->plural() + ->toString(); + } + + return get_class($class); + } +} diff --git a/src/Search/QueryBuilder.php b/src/Search/QueryBuilder.php index 100f7acf83..f2f09492df 100644 --- a/src/Search/QueryBuilder.php +++ b/src/Search/QueryBuilder.php @@ -4,12 +4,15 @@ use Statamic\Contracts\Search\Result; use Statamic\Data\DataCollection; +use Statamic\Query\Concerns\FakesQueries; use Statamic\Query\IteratorBuilder as BaseQueryBuilder; use Statamic\Search\Searchables\Providers; use Statamic\Support\Str; abstract class QueryBuilder extends BaseQueryBuilder { + use FakesQueries; + protected $query; protected $index; protected $withData = true; @@ -40,6 +43,11 @@ public function withoutData() return $this; } + public function get($columns = ['*']) + { + return $this->withFakeQueryLogging(fn () => parent::get($columns)); + } + public function getBaseItems() { $results = $this->getSearchResults($this->query); @@ -73,4 +81,9 @@ protected function collect($items = []) { return new DataCollection($items); } + + public function getTableNameForFakeQuery() + { + return 'search_'.$this->index->name(); + } } diff --git a/src/Stache/Query/Builder.php b/src/Stache/Query/Builder.php index e79fa7a601..dc9dbbbafc 100644 --- a/src/Stache/Query/Builder.php +++ b/src/Stache/Query/Builder.php @@ -32,18 +32,24 @@ private function resolveKeys() public function pluck($column, $key = null) { - return $this->store->getItemValues($this->resolveKeys(), $column, $key); + return $this->onceWithColumns(array_filter([$column, $key]), function () use ($column, $key) { + return $this->withFakeQueryLogging(function () use ($column, $key) { + return $this->store->getItemValues($this->resolveKeys(), $column, $key); + }); + }); } public function get($columns = ['*']) { - $items = $this->getItems($this->resolveKeys()); + return $this->onceWithColumns($columns, fn () => $this->withFakeQueryLogging(function () { + $items = $this->getItems($this->resolveKeys()); - $items->each(fn ($item) => $item - ->selectedQueryColumns($this->columns ?? $columns) - ->selectedQueryRelations($this->with)); + $items->each(fn ($item) => $item + ->selectedQueryColumns($this->columns) + ->selectedQueryRelations($this->with)); - return $this->collect($items)->values(); + return $this->collect($items)->values(); + })); } abstract protected function getFilteredKeys(); diff --git a/src/Stache/Query/EntryQueryBuilder.php b/src/Stache/Query/EntryQueryBuilder.php index e43fe045b5..fbf92cd17e 100644 --- a/src/Stache/Query/EntryQueryBuilder.php +++ b/src/Stache/Query/EntryQueryBuilder.php @@ -5,6 +5,7 @@ use Statamic\Contracts\Entries\QueryBuilder; use Statamic\Entries\EntryCollection; use Statamic\Facades; +use Statamic\Support\Arr; class EntryQueryBuilder extends Builder implements QueryBuilder { @@ -131,4 +132,20 @@ protected function getWhereColumnKeyValuesByIndex($column) return $this->getWhereColumnKeysFromStore($collection, ['column' => $column]); }); } + + public function prepareForFakeQuery(): array + { + $data = parent::prepareForFakeQuery(); + + if (! empty($this->collections)) { + $data['wheres'] = Arr::prepend($data['wheres'], [ + 'type' => 'In', + 'column' => 'collection', + 'values' => $this->collections, + 'boolean' => 'and', + ]); + } + + return $data; + } } diff --git a/src/Stache/Query/TermQueryBuilder.php b/src/Stache/Query/TermQueryBuilder.php index 3dca2d45cf..deda55b377 100644 --- a/src/Stache/Query/TermQueryBuilder.php +++ b/src/Stache/Query/TermQueryBuilder.php @@ -4,6 +4,7 @@ use Statamic\Facades; use Statamic\Facades\Collection; +use Statamic\Support\Arr; use Statamic\Taxonomies\TermCollection; class TermQueryBuilder extends Builder @@ -176,4 +177,29 @@ protected function getWhereColumnKeyValuesByIndex($column) return $items; } + + public function prepareForFakeQuery(): array + { + $data = parent::prepareForFakeQuery(); + + if (! empty($this->taxonomies)) { + $data['wheres'] = Arr::prepend($data['wheres'], [ + 'type' => 'In', + 'column' => 'taxonomy', + 'values' => $this->taxonomies, + 'boolean' => 'and', + ]); + } + + if (! empty($this->collections)) { + $data['wheres'] = Arr::prepend($data['wheres'], [ + 'type' => 'In', + 'column' => 'collection', + 'values' => $this->collections, + 'boolean' => 'and', + ]); + } + + return $data; + } }