Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@ DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=5432 DB_DATABASE=testing DB_USERNA

CI runs the same two commands against `mysql:8` and `postgres:16` service containers; see `.github/workflows/integration-tests.yml`.

## AI guidelines (Laravel Boost)

This package ships AI guidelines for [Laravel Boost](https://github.com/laravel/boost), at the path the Boost documentation gives package authors: `resources/boost/guidelines/core.blade.php`. It is a short, always in context brief that tells an agent to reach for `LikeOperator`, `YearExpression` and `CsvValues` instead of writing `LIKE '%...%'`, `YEAR(...)` or `explode(',', ...)` by hand, and which drivers are supported.

Boost finds the guidelines by looking for a `resources/boost` folder in the packages your application requires directly, so the missing service provider makes no difference. Install Boost 2.5 or higher with `composer require laravel/boost --dev` then `php artisan boost:install`, selecting this package when Boost asks which third party guidelines to install. On an app that already has Boost installed, `php artisan boost:update --discover` offers the newly detected package. The package has to be a direct requirement of the application (listed in its `composer.json`), because Boost ignores guidelines from transitive dependencies.

## Changelog

Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
"phpstan/phpstan-phpunit": "^2.0",
"rector/rector": "^2.0"
},
"suggest": {
"laravel/boost": "2.5 or higher reads the AI guidelines shipped in resources/boost"
},
"autoload": {
"psr-4": {
"PlinCode\\SqlDialect\\": "src/"
Expand Down
48 changes: 48 additions & 0 deletions resources/boost/guidelines/core.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
## Laravel SQL Dialect

Static helpers in `PlinCode\SqlDialect` for SQL that must behave the same on PostgreSQL, MySQL and SQLite. There is no service provider, config, facade or macro: import the class and call it. Use them instead of writing the driver specific SQL by hand.

### Partial text matches

Do not write `->where('title', 'like', "%{$term}%")` with a user supplied term: `%`, `_` and `\` in the term act as wildcards, and case sensitivity differs per driver. `LikeOperator::applyContains()` escapes the term, binds it, adds the right `ESCAPE` clause, and uses `ILIKE` on PostgreSQL and `LIKE` elsewhere.

@verbatim
<code-snippet name="Wildcard safe contains filter" lang="php">
use Illuminate\Database\Eloquent\Builder;
use PlinCode\SqlDialect\LikeOperator;

Movie::query()
->where(function (Builder $query) use ($term, $excluded) {
LikeOperator::applyContains($query, 'title', $term);
LikeOperator::applyNotContains($query, 'title', $excluded);
})
->get();
</code-snippet>
@endverbatim

- Variants: `LikeOperator::orApplyContains()`, `LikeOperator::applyNotContains()`, `LikeOperator::orApplyNotContains()`. Wrap OR clauses in a closure so they do not leak into the rest of the query. A `NULL` column matches neither the positive nor the negated form.
- `LikeOperator::applyContainsOnDate()` matches a partial date by casting the column to text first, instead of a hand written `CAST(... AS CHAR)` or `::text`.
- These methods take `Illuminate\Database\Eloquent\Builder` only, not `Illuminate\Database\Query\Builder` (so not inside `whereExists()` or `orWhereIn()` closures).
- `$column` is interpolated into raw SQL: pass a column name from your code, never request input. The term is always bound.
- Building SQL by hand: `LikeOperator::for()` returns the operator, `LikeOperator::containsPattern()` the escaped `%term%` pattern.

### Year of a date column

Do not write `YEAR(...)`, `EXTRACT(YEAR FROM ...)` or `strftime('%Y', ...)` in `whereRaw()` or `orderByRaw()`. Use `YearExpression::numeric()` (integer, for ordering and comparisons) or `YearExpression::text()` (text, for display).

@verbatim
<code-snippet name="Year expressions" lang="php">
use PlinCode\SqlDialect\YearExpression;

$query->whereRaw(YearExpression::numeric($query, 'release_date').' = ?', [$year]);
$query->orderByRaw(YearExpression::numeric($query, 'release_date').' desc');
</code-snippet>
@endverbatim

### Multi value filters

Instead of `explode(',', ...)` plus `trim()` and `array_filter()`, call `CsvValues::parse($value)`. It accepts an array, a comma separated string or a scalar and returns a `list<string>`: trimmed, blanks dropped, order kept. Non scalar entries are dropped silently.

### Drivers

Supported: `pgsql`, `mysql`, `mariadb` (handled but not covered by the test suite), `sqlite`. Any other driver, `sqlsrv` included, is unsupported and raises no error up front: `YearExpression::numeric()`, `YearExpression::text()` and `LikeOperator::applyContainsOnDate()` fall back to syntax that fails at the database at query time.
280 changes: 280 additions & 0 deletions tests/Unit/BoostResourcesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Support\Facades\Blade;

/**
* Class names the guideline uses as stand ins for application code.
* They are neither imported nor part of the package, so the drift checks
* skip them. Keep this list short and obviously fictional.
*/
const BOOST_EXAMPLE_CLASSES = ['Movie'];

function boostGuideline(): string
{
return Blade::render((string) file_get_contents(__DIR__.'/../../resources/boost/guidelines/core.blade.php'));
}

/**
* @return array<string, mixed>
*/
function boostComposer(): array
{
/** @var array<string, mixed> */
return json_decode((string) file_get_contents(__DIR__.'/../../composer.json'), true, flags: JSON_THROW_ON_ERROR);
}

function boostPackageNamespace(): string
{
/** @var array{autoload: array{psr-4: array<string, string>}} $composer */
$composer = boostComposer();

return rtrim((string) array_key_first($composer['autoload']['psr-4']), '\\');
}

/**
* The bodies of the code-snippet blocks, where calls carry real arguments.
*
* @return list<string>
*/
function boostSnippets(string $rendered): array
{
preg_match_all('/<code-snippet[^>]*>(.*?)<\/code-snippet>/s', $rendered, $matches);

return $matches[1];
}

/**
* Maps the short names imported by `use` lines in the snippets to their FQCN.
*
* @return array<string, string>
*/
function boostImports(string $rendered): array
{
preg_match_all('/^use\s+([\w\\\\]+)(?:\s+as\s+(\w+))?;/m', $rendered, $matches, PREG_SET_ORDER);

$imports = [];

foreach ($matches as $match) {
$alias = ($match[2] ?? '') !== '' ? $match[2] : class_basename($match[1]);
$imports[$alias] = $match[1];
}

return $imports;
}

/**
* Resolves a short class name the way a reader would: an import first,
* then the package namespace. Null for a declared example class.
*/
function boostResolveClass(string $name, string $rendered): ?string
{
if (in_array($name, BOOST_EXAMPLE_CLASSES, true)) {
return null;
}

return boostImports($rendered)[$name] ?? boostPackageNamespace().'\\'.$name;
}

/**
* Splits the argument list starting right after an opening parenthesis
* into top level arguments, honouring nesting and quoted strings.
*
* @return list<string>
*/
function boostArguments(string $source, int $offset): array
{
$arguments = [];
$current = '';
$depth = 0;
$quote = null;
$length = strlen($source);

for ($i = $offset; $i < $length; $i++) {
$char = $source[$i];

if ($quote !== null) {
$current .= $char;

if ($char === '\\') {
$current .= $source[++$i] ?? '';
} elseif ($char === $quote) {
$quote = null;
}

continue;
}

if ($char === '"' || $char === "'") {
$quote = $char;
} elseif (in_array($char, ['(', '[', '{'], true)) {
$depth++;
} elseif (in_array($char, [')', ']', '}'], true)) {
if ($depth === 0) {
break;
}

$depth--;
} elseif ($char === ',' && $depth === 0) {
$arguments[] = trim($current);
$current = '';

continue;
}

$current .= $char;
}

if (trim($current) !== '') {
$arguments[] = trim($current);
}

return $arguments;
}

describe('Boost guidelines', function (): void {
it('ships a single core guidelines file', function (): void {
expect(glob(__DIR__.'/../../resources/boost/guidelines/*'))
->toBe([__DIR__.'/../../resources/boost/guidelines/core.blade.php']);
});

it('renders as blade without leaving directives behind', function (): void {
expect(boostGuideline())->not->toContain('@verbatim')
->not->toContain('@endverbatim')
->not->toContain('{{');
});

it('wraps its code examples in code-snippet tags', function (): void {
expect(boostGuideline())->toContain('<code-snippet')
->toContain('</code-snippet>');
});

it('keeps the guidelines short enough to stay in context', function (): void {
expect(strlen(boostGuideline()))->toBeLessThan(6000);
});

it('suggests laravel boost without requiring it', function (): void {
$composer = boostComposer();

expect($composer['suggest']['laravel/boost'] ?? null)->toBeString()->not->toBe('')
->and($composer['require']['laravel/boost'] ?? null)->toBeNull()
->and($composer['require-dev']['laravel/boost'] ?? null)->toBeNull();
});
});

describe('Boost guidelines drift', function (): void {
it('names only classes and namespaces that exist', function (): void {
preg_match_all('/\b[A-Z]\w*(?:\\\\[A-Z]\w*)+\b/', boostGuideline(), $matches);

$names = array_values(array_unique($matches[0]));

expect($names)->not->toBeEmpty();

foreach ($names as $name) {
expect(class_exists($name) || $name === boostPackageNamespace())
->toBeTrue("The guideline names {$name}, which is neither a class nor the package namespace.");
}
});

it('calls only static methods that exist', function (): void {
$rendered = boostGuideline();

preg_match_all('/\b([A-Z]\w*)::(\w+)\(/', $rendered, $matches, PREG_SET_ORDER);

expect($matches)->not->toBeEmpty();

foreach ($matches as [, $class, $method]) {
$fqcn = boostResolveClass($class, $rendered);

if ($fqcn === null) {
continue;
}

expect(class_exists($fqcn))->toBeTrue("The guideline uses {$class}::{$method}(), but {$fqcn} does not exist.")
->and(method_exists($fqcn, $method))->toBeTrue("The guideline uses {$class}::{$method}(), which {$fqcn} does not declare.");

expect(new ReflectionMethod($fqcn, $method)->isPublic())
->toBeTrue("The guideline uses {$class}::{$method}(), which is not public.");
}
});

it('passes arguments that match the signature in every snippet call', function (): void {
$rendered = boostGuideline();
$checked = 0;

foreach (boostSnippets($rendered) as $snippet) {
preg_match_all('/\b([A-Z]\w*)::(\w+)\(/', $snippet, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);

foreach ($matches as $match) {
[$call, $offset] = $match[0];
$fqcn = boostResolveClass($match[1][0], $rendered);

if ($fqcn === null || ! method_exists($fqcn, $match[2][0])) {
continue;
}

$reflection = new ReflectionMethod($fqcn, $match[2][0]);
$parameters = array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $reflection->getParameters());
$arguments = boostArguments($snippet, $offset + strlen($call));

expect(count($arguments))->toBeGreaterThanOrEqual($reflection->getNumberOfRequiredParameters(), "{$call}) passes too few arguments.")
->toBeLessThanOrEqual($reflection->getNumberOfParameters(), "{$call}) passes too many arguments.");

foreach ($arguments as $argument) {
if (preg_match('/^(\w+):(?!:)/', $argument, $named) === 1) {
expect($parameters)->toContain($named[1]);
}
}

$checked++;
}
}

expect($checked)->toBeGreaterThan(0);
});

it('chains only builder methods that exist', function (): void {
preg_match_all('/->(\w+)\(/', boostGuideline(), $matches);

$methods = array_values(array_unique($matches[1]));

expect($methods)->not->toBeEmpty();

foreach ($methods as $method) {
expect(method_exists(EloquentBuilder::class, $method) || method_exists(QueryBuilder::class, $method))
->toBeTrue("The guideline chains ->{$method}(), which neither builder declares.");
}
});

it('lists exactly the drivers the package branches on as supported', function (): void {
$drivers = [];

foreach (glob(__DIR__.'/../../src/*.php') ?: [] as $file) {
$source = (string) file_get_contents($file);

preg_match_all('/match\s*\([^{]*?\)\s*\{(.*?)^\s*\};/ms', $source, $blocks);

foreach ($blocks[1] as $block) {
preg_match_all("/'(\\w+)'\\s*(?:,|=>)/", $block, $arms);
$drivers = [...$drivers, ...$arms[1]];
}

preg_match_all("/(?:getDriverName\\(\\)|\\\$driver)\\s*===\\s*'(\\w+)'/", $source, $comparisons);
$drivers = [...$drivers, ...$comparisons[1]];
}

$drivers = array_unique($drivers);
sort($drivers);

preg_match('/^Supported:([^.]*)\./m', boostGuideline(), $line);
preg_match_all('/`(\w+)`/', $line[1] ?? '', $listed);
$listed = array_unique($listed[1]);
sort($listed);

expect($drivers)->toContain('pgsql', 'mysql', 'sqlite')
->and($listed)->toBe($drivers);
});
});
Loading