From d84e1e8ea1c860b6bde8e0c23e89bd593c644ced Mon Sep 17 00:00:00 2001 From: Daniele Barbaro Date: Fri, 2 Oct 2026 08:58:59 +0200 Subject: [PATCH] feat(boost): ship ai guidelines for the package --- README.md | 6 + composer.json | 3 + resources/boost/guidelines/core.blade.php | 48 ++++ tests/Unit/BoostResourcesTest.php | 280 ++++++++++++++++++++++ 4 files changed, 337 insertions(+) create mode 100644 resources/boost/guidelines/core.blade.php create mode 100644 tests/Unit/BoostResourcesTest.php diff --git a/README.md b/README.md index 029041f..dbb5ea8 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/composer.json b/composer.json index 47ef975..36cd299 100644 --- a/composer.json +++ b/composer.json @@ -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/" diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php new file mode 100644 index 0000000..a375d40 --- /dev/null +++ b/resources/boost/guidelines/core.blade.php @@ -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 + +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(); + +@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 + +use PlinCode\SqlDialect\YearExpression; + +$query->whereRaw(YearExpression::numeric($query, 'release_date').' = ?', [$year]); +$query->orderByRaw(YearExpression::numeric($query, 'release_date').' desc'); + +@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`: 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. diff --git a/tests/Unit/BoostResourcesTest.php b/tests/Unit/BoostResourcesTest.php new file mode 100644 index 0000000..f708c5a --- /dev/null +++ b/tests/Unit/BoostResourcesTest.php @@ -0,0 +1,280 @@ + + */ +function boostComposer(): array +{ + /** @var array */ + 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}} $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 + */ +function boostSnippets(string $rendered): array +{ + preg_match_all('/]*>(.*?)<\/code-snippet>/s', $rendered, $matches); + + return $matches[1]; +} + +/** + * Maps the short names imported by `use` lines in the snippets to their FQCN. + * + * @return array + */ +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 + */ +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('toContain(''); + }); + + 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); + }); +});