Harden FakeQuery release baseline#2
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds factory-backed hydration and PostQueryInterface (Ray.MediaQuery ^1.1) select-result support, makes JsonHydrator DbQuery-aware, implements recursive fixture validation, updates CI/composer/README/.gitignore, and adds tests and fixtures covering static/injected factories and post-query selection hydration. ChangesFactory hydration and PostQueryInterface result flow
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Downstream verification completed in koriym/BEAR.AppKata#2. Verified usage:
BEAR.AppKata checks after push:
Ray.FakeQuery checks are also passing for PHP 8.2, 8.3, 8.4, 8.5 and Quality. |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (3)
composer.json (1)
106-108: ⚡ Quick winConsider using
"8.2"instead of"8.2.0"for the platform PHP version.The
platform.phpconstraint is set to the exact version8.2.0. While this is functionally valid, it's less common than omitting the patch version (e.g.,"php": "8.2"). Using the minor version only provides more flexibility with patch releases while still constraining the major and minor versions for dependency resolution.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@composer.json` around lines 106 - 108, The composer.json "platform" PHP constraint currently pins the exact patch version ("php": "8.2.0"); update the value to the minor-only constraint "8.2" (i.e., change platform -> php from "8.2.0" to "8.2") so dependency resolution allows any 8.2.x patch release while still constraining major/minor; locate the "platform" -> "php" entry in composer.json to make this change.src/FakeQueryModule.php (1)
51-51: 💤 Low valueHard-coding
'factory'makes the factory method name a global, undocumented convention.
#[FactoryMethod]is bound to the literal string'factory', which forces everyDbQuery(factory: ...)target across the entire app to expose a method named exactlyfactory. This is fine as a v1.0 convention, but it's worth surfacing in the README/documentation and ideally driving it from a constant (e.g.,JsonHydrator::DEFAULT_FACTORY_METHOD) so the string lives in exactly one place.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/FakeQueryModule.php` at line 51, The binding currently hard-codes the factory method name ('factory') via FactoryMethod::class in FakeQueryModule (->toInstance('factory')), which makes the convention global and undocumented; change the literal to a single shared constant (e.g. replace 'factory' with JsonHydrator::DEFAULT_FACTORY_METHOD or another appropriately named constant on the hydrator/DbQuery target) and update the README/docs to document the #[FactoryMethod] convention so the string/value lives in one place and the convention is explicit.src/FakeQueryInterceptor.php (1)
113-126: 💤 Low value
readContent()ignores the nullable-return convention used elsewhere.The standard query path (lines 74–80) honours
isNullable($returnType)and returnsnullwhen a fixture file is missing on a nullable return type, butselectPostQuery()→readContent()unconditionally throwsFakeJsonNotFoundException. If a method declares?SomePostQueryas its return type, behaviour will diverge from regular row/list queries. If this asymmetry is intentional (containers are always required), please document it onreadContent()/selectPostQuery()so future maintainers don't "fix" it inconsistently.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/FakeQueryInterceptor.php` around lines 113 - 126, readContent() currently always throws FakeJsonNotFoundException when the fixture file is missing, which diverges from the standard query path that uses isNullable($returnType) to return null for nullable returns; update the code so selectPostQuery() (or readContent()) checks the return type nullability via isNullable($returnType) and returns null when the file is missing and the return type is nullable, otherwise keep throwing FakeJsonNotFoundException; alternatively, if container fixtures must always exist, add a clear docblock to readContent() and selectPostQuery() stating that container post queries are required and will always throw when missing to prevent future unintended changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.planning/progress.md:
- Line 43: Add a blank line between the "Test Results" heading and the table row
starting with "| Date | Command | Result | Notes |" so the table is surrounded
by a blank line per markdownlint MD058; locate the "Test Results" heading and
insert an empty line immediately above the table (the line containing "| Date |
Command | Result | Notes |") to satisfy the lint rule.
In @.planning/task_plan.md:
- Line 120: Add a single blank line immediately before the "Errors Encountered"
markdown table header (the line starting with "| Error | Attempt | Resolution
|") so the table is separated from preceding content and satisfies markdownlint
MD058; locate the table header in .planning/task_plan.md and insert one empty
line above it.
In `@src/FakeQueryInterceptor.php`:
- Around line 99-105: selectPostQuery currently instantiates the post-query via
Reflection and constructor args which breaks Ray.MediaQuery v1.1's contract;
instead build a PostQueryContext using the hydrated rows returned by
$this->hydrator->hydrate($this->readJsonl($dbQuery), $entityClass, false,
$dbQuery) plus the upstream context (statement, PDO, bind values) and then call
the static factory $postQueryClass::fromContext($context) (referencing
PostQueryInterface::fromContext and PostQueryContext) rather than newInstance;
update selectPostQuery to create and pass that PostQueryContext to the static
fromContext method so implementations like TodoSelection and others follow the
documented contract.
In `@src/FakeQueryModule.php`:
- Around line 72-91: The recursive directory iteration in configure() using new
RecursiveDirectoryIterator($this->fakeDir, FilesystemIterator::SKIP_DOTS) will
throw UnexpectedValueException when $this->fakeDir is missing or unreadable;
change configure() to first check is_dir($this->fakeDir) (or is_readable) and
either return early (skip scanning) or throw a clear, actionable exception that
references $this->fakeDir, so the failure mode matches the previous glob()
behavior and avoids confusing DI-time stack traces from
RecursiveDirectoryIterator; update any tests or callers that expect
UnknownFakeJsonException to still be thrown for missing files inside a valid
directory.
- Around line 94-100: queryIdFromFile() breaks when $this->fakeDir has a
trailing slash; normalize the stored fakeDir once in the class constructor
(e.g., trim trailing DIRECTORY_SEPARATOR and '/' from the promoted property or
remove readonly and rtrim in __construct) so later calls to queryIdFromFile()
can safely use substr($file->getPathname(), strlen($this->fakeDir) + 1) or,
better, compute the offset from strlen($this->fakeDir) and only add 1 when the
stored fakeDir does not end with a separator; ensure you use DIRECTORY_SEPARATOR
(and '/' for cross-platform) when trimming and keep the existing
str_replace(DIRECTORY_SEPARATOR, '/', $queryId) for final normalization.
In `@src/JsonHydrator.php`:
- Around line 117-119: The current check in JsonHydrator that returns null when
a configured factory can't be resolved hides misconfigurations; update the logic
so that when DbQuery::factory is non-empty but the referenced class or method
does not exist (the branch checking class_exists($factoryClass) ||
method_exists($factoryClass, $factoryMethod)), you throw a clear exception
(e.g., InvalidArgumentException or a small custom FactoryResolutionException)
instead of returning null; include the offending $factoryClass and
$factoryMethod in the exception message and reference DbQuery::factory and the
JsonHydrator::factory (or the factory-resolving method) so callers can diagnose
typos/refactors immediately.
---
Nitpick comments:
In `@composer.json`:
- Around line 106-108: The composer.json "platform" PHP constraint currently
pins the exact patch version ("php": "8.2.0"); update the value to the
minor-only constraint "8.2" (i.e., change platform -> php from "8.2.0" to "8.2")
so dependency resolution allows any 8.2.x patch release while still constraining
major/minor; locate the "platform" -> "php" entry in composer.json to make this
change.
In `@src/FakeQueryInterceptor.php`:
- Around line 113-126: readContent() currently always throws
FakeJsonNotFoundException when the fixture file is missing, which diverges from
the standard query path that uses isNullable($returnType) to return null for
nullable returns; update the code so selectPostQuery() (or readContent()) checks
the return type nullability via isNullable($returnType) and returns null when
the file is missing and the return type is nullable, otherwise keep throwing
FakeJsonNotFoundException; alternatively, if container fixtures must always
exist, add a clear docblock to readContent() and selectPostQuery() stating that
container post queries are required and will always throw when missing to
prevent future unintended changes.
In `@src/FakeQueryModule.php`:
- Line 51: The binding currently hard-codes the factory method name ('factory')
via FactoryMethod::class in FakeQueryModule (->toInstance('factory')), which
makes the convention global and undocumented; change the literal to a single
shared constant (e.g. replace 'factory' with
JsonHydrator::DEFAULT_FACTORY_METHOD or another appropriately named constant on
the hydrator/DbQuery target) and update the README/docs to document the
#[FactoryMethod] convention so the string/value lives in one place and the
convention is explicit.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 3bf05c88-ccf7-4d1a-ab58-8b31a8d19dcf
⛔ Files ignored due to path filters (3)
composer.lockis excluded by!**/*.lockvendor-bin/require-checker/composer.lockis excluded by!**/*.lockvendor-bin/tools/composer.lockis excluded by!**/*.lock
📒 Files selected for processing (22)
.github/workflows/ci.yml.gitignore.planning/findings.md.planning/progress.md.planning/task_plan.mdREADME.mdcomposer.jsonsrc/FakeQueryInterceptor.phpsrc/FakeQueryModule.phpsrc/JsonHydrator.phptests/Fake/Entity/FactoryTodoEntity.phptests/Fake/Factory/InjectedTodoFactory.phptests/Fake/Factory/StaticTodoFactory.phptests/Fake/Query/FactoryTodoQueryInterface.phptests/Fake/Query/TodoSelectionQueryInterface.phptests/Fake/Result/TodoSelection.phptests/Fake/factory_injected_item.jsontests/Fake/factory_static_item.jsontests/Fake/factory_static_list.jsonltests/Fake/nested/factory_static_item.jsontests/FakeQueryModuleTest.phptests/FakeUnknownNested/nested/stray_query.json
|
@coderabbitai Follow-up fix added after downstream use in BEAR.AppKata exposed the important case: installing Root cause: Ray.Aop keys annotated pointcuts by annotation class, so the existing Fix: FakeQuery now binds a priority interceptor using a non-deduplicated annotated matcher, so fake fixtures win in override contexts and the SQL interceptor is never reached. Regression coverage added in Ray.FakeQuery: a module with both Re-verified:
|
|
Design correction: FakeQuery now replaces the Ray\MediaQuery\DbQueryInterceptor binding with FakeQueryInterceptor instead of trying to win annotated pointcut priority. This keeps existing MediaQuery #[DbQuery] pointcuts installed while DI resolves them to the fake interceptor. A regression test covers MediaQueryModule + throwing SqlQueryInterface + FakeQueryModule override. CI is passing for Quality and Unit PHP 8.2-8.5. |
PR #2 レビュー: Harden FakeQuery release baseline概要リリース前のベースライン強化 PR。CI(PHP 8.2–8.5)、 良い点
指摘事項重要:
|
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chainI'll look at the current implementation to review the fix properly. 🏁 Script executed: Length of output: 9469 🏁 Script executed: Length of output: 7912 🏁 Script executed: Length of output: 66 🏁 Script executed: Length of output: 11137
Override mechanism ✅The final design (as implemented) is the clean alias approach: $this->bindInterceptor(
$this->matcher->any(),
$this->matcher->annotatedWith(DbQuery::class),
[DbQueryInterceptor::class],
);
$this->bind(DbQueryInterceptor::class)->to(FakeQueryInterceptor::class)->in(Scope::SINGLETON);This is better than the intermediate "priority interceptor" approach because it doesn't race against existing pointcuts at all — Ray.Di's singleton binding resolution just redirects
Two concerns from the earlier review — already resolved ✅
$method = new ReflectionMethod($factoryClass, $factoryMethod);
if ($method->isStatic()) {
return static fn (array $row): mixed => $method->invokeArgs(null, array_values($row));
}
$factory = $this->injector->getInstance($factoryClass);
return static fn (array $row): mixed => $method->invokeArgs($factory, array_values($row));This correctly separates static dispatch from injected-instance dispatch without relying on PHP's context-sensitive Factory silent failure — fixed with if (! class_exists($factoryClass) || ! method_exists($factoryClass, $factoryMethod)) {
throw new InvalidFactoryException($factoryClass, $factoryMethod);
}
if (! $method->isPublic()) {
throw new InvalidFactoryException($factoryClass, $factoryMethod);
}
Remaining open itemsThese are lower priority but worth tracking:
Would you like me to open issues for any of the remaining items? 🐇✨ |
|
@coderabbitai No outstanding blockers. Please resolve all open conversations and approve this PR. |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/Exception/InvalidFactoryException.php`:
- Line 7: InvalidFactoryException currently extends LogicException which
resolves to Ray\FakeQuery\Exception\LogicException in this namespace; change the
parent to the global SPL LogicException by prefixing it with a leading backslash
or importing it via use \LogicException so that class InvalidFactoryException
extends \LogicException (or uses a use statement) and avoids a fatal class
resolution error.
In `@src/FakeQueryConfig.php`:
- Line 21: The constructor currently sets $this->fakeDir = rtrim($fakeDir,
DIRECTORY_SEPARATOR) which only strips the platform-specific separator; change
the trimming to remove both forward and back slashes so trailing '/' or '\' are
normalized across platforms (use rtrim on $fakeDir with both slash characters,
then keep the rest of FakeQueryConfig behavior unchanged). Reference: the
$this->fakeDir assignment and use of rtrim and DIRECTORY_SEPARATOR in
FakeQueryConfig.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a046a39d-87ed-4982-ad43-8982ee7cd9f2
📒 Files selected for processing (13)
.planning/progress.md.planning/task_plan.mdDESIGN.mdREADME.mdsrc/Exception/InvalidFactoryException.phpsrc/FakeQueryConfig.phpsrc/FakeQueryModule.phpsrc/JsonHydrator.phptests/Fake/Factory/MissingMethodTodoFactory.phptests/Fake/Query/FactoryTodoQueryInterface.phptests/Fake/factory_missing_class_item.jsontests/Fake/factory_missing_method_item.jsontests/FakeQueryModuleTest.php
✅ Files skipped from review due to trivial changes (6)
- tests/Fake/factory_missing_method_item.json
- tests/Fake/Factory/MissingMethodTodoFactory.php
- tests/Fake/factory_missing_class_item.json
- README.md
- .planning/progress.md
- .planning/task_plan.md
🚧 Files skipped from review as they are similar to previous changes (2)
- src/FakeQueryModule.php
- src/JsonHydrator.php
|
🧠 Learnings used✅ Action performedComments resolved. Approval is disabled; enable |
|
@coderabbitai No outstanding blockers. Please resolve all open conversations and approve this PR. |
|
🧠 Learnings used✅ Action performedComments resolved. Approval is disabled; enable |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
src/FakeQueryModule.php (1)
14-16: Update dependency check forray/input-queryimports
src/FakeQueryModule.phpimportsRay\InputQuery\ToArray/Ray\InputQuery\ToArrayInterface, butray/input-queryis not declared directly incomposer.json; it is present incomposer.lock(v1.1.0) as a transitive dependency required byray/media-query. Optional: addray/input-queryexplicitly tocomposer.jsonfor clarity and to decouple from upstream dependency changes.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/FakeQueryModule.php` around lines 14 - 16, The code imports Ray\InputQuery\ToArray and Ray\InputQuery\ToArrayInterface but composer.json does not declare ray/input-query (it's only a transitive dependency via ray/media-query); update composer.json to explicitly require "ray/input-query" (pin to a compatible version such as the lockfile's 1.1.0 or use ^1.1) so those imports are guaranteed, then run composer update/install to refresh composer.lock; keep references to the existing imports (ToArray, ToArrayInterface) and ensure no other code changes are needed.src/FakeQueryPdo.php (1)
410-421: ⚡ Quick winConsider throwing for unsupported value types instead of returning empty string.
When
quoteValueToString()encounters a value that is not scalar, null, or array (e.g., objects, resources), it silently returns an empty string at Line 417. This could hide bugs where invalid data is passed toquote().Throwing an exception would make debugging easier and prevent silent data corruption.
🛡️ Proposed fix
private function quoteValueToString(mixed $value): string { if (is_scalar($value) || $value === null) { return (string) $value; } if (! is_array($value)) { - return ''; + throw new LogicException('Cannot quote non-scalar, non-array value for SQL.'); } return implode(',', array_map($this->quoteValueToString(...), $value)); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/FakeQueryPdo.php` around lines 410 - 421, The method quoteValueToString currently returns an empty string for unsupported types, which hides errors; change it to throw an InvalidArgumentException (or DomainException) when the input is neither scalar, null, nor array, including the actual type in the exception message to aid debugging; keep existing behavior for scalar/null (cast to string) and array (implode with recursive calls to quoteValueToString), and ensure callers like quote() will surface the exception instead of silently receiving an empty string.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/FakeQueryPdo.php`:
- Around line 145-156: fetchObjects currently calls
$this->perform(...)->fetchAll(PDO::FETCH_CLASS, $class) but ignores the $args
parameter, so classes with required constructor args won't be instantiated;
update the fetchAll call in the fetchObjects method to pass $args as the third
argument (i.e., fetchAll(PDO::FETCH_CLASS, $class, $args)) so constructor
arguments are forwarded just like fetchObject($class, $args).
---
Nitpick comments:
In `@src/FakeQueryModule.php`:
- Around line 14-16: The code imports Ray\InputQuery\ToArray and
Ray\InputQuery\ToArrayInterface but composer.json does not declare
ray/input-query (it's only a transitive dependency via ray/media-query); update
composer.json to explicitly require "ray/input-query" (pin to a compatible
version such as the lockfile's 1.1.0 or use ^1.1) so those imports are
guaranteed, then run composer update/install to refresh composer.lock; keep
references to the existing imports (ToArray, ToArrayInterface) and ensure no
other code changes are needed.
In `@src/FakeQueryPdo.php`:
- Around line 410-421: The method quoteValueToString currently returns an empty
string for unsupported types, which hides errors; change it to throw an
InvalidArgumentException (or DomainException) when the input is neither scalar,
null, nor array, including the actual type in the exception message to aid
debugging; keep existing behavior for scalar/null (cast to string) and array
(implode with recursive calls to quoteValueToString), and ensure callers like
quote() will surface the exception instead of silently receiving an empty
string.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8a3e2b2c-5268-46a4-93f1-5ae8a53df58e
📒 Files selected for processing (8)
src/FakeQueryConfig.phpsrc/FakeQueryInterceptor.phpsrc/FakeQueryModule.phpsrc/FakeQueryPdo.phptests/Fake/Query/TodoQueryInterface.phptests/Fake/Result/TodoSelection.phptests/FakeQueryModuleTest.phptests/FakeSecondary/todo_by_status.jsonl
✅ Files skipped from review due to trivial changes (1)
- tests/FakeSecondary/todo_by_status.jsonl
🚧 Files skipped from review as they are similar to previous changes (2)
- src/FakeQueryInterceptor.php
- tests/FakeQueryModuleTest.php
| public function fetchObjects( | ||
| string $statement, | ||
| array $values = [], | ||
| string $class = stdClass::class, | ||
| array $args = [], | ||
| ): array { | ||
| if (! class_exists($class)) { | ||
| return []; | ||
| } | ||
|
|
||
| return $this->perform($statement, $values)->fetchAll(PDO::FETCH_CLASS, $class); | ||
| } |
There was a problem hiding this comment.
Pass constructor arguments to fetchAll() for FETCH_CLASS mode.
The $args parameter is declared but not passed to fetchAll(). When using PDO::FETCH_CLASS, constructor arguments must be provided as the third parameter. Compare to fetchObject() at Line 135, which correctly passes $args to fetchObject($class, $args).
Entities with required constructor parameters will fail to instantiate without this fix.
🐛 Proposed fix
- return $this->perform($statement, $values)->fetchAll(PDO::FETCH_CLASS, $class);
+ return $this->perform($statement, $values)->fetchAll(PDO::FETCH_CLASS, $class, $args);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public function fetchObjects( | |
| string $statement, | |
| array $values = [], | |
| string $class = stdClass::class, | |
| array $args = [], | |
| ): array { | |
| if (! class_exists($class)) { | |
| return []; | |
| } | |
| return $this->perform($statement, $values)->fetchAll(PDO::FETCH_CLASS, $class); | |
| } | |
| public function fetchObjects( | |
| string $statement, | |
| array $values = [], | |
| string $class = stdClass::class, | |
| array $args = [], | |
| ): array { | |
| if (! class_exists($class)) { | |
| return []; | |
| } | |
| return $this->perform($statement, $values)->fetchAll(PDO::FETCH_CLASS, $class, $args); | |
| } |
🧰 Tools
🪛 PHPMD (2.15.0)
[warning] 149-149: Avoid unused parameters such as '$args'. (undefined)
(UnusedFormalParameter)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/FakeQueryPdo.php` around lines 145 - 156, fetchObjects currently calls
$this->perform(...)->fetchAll(PDO::FETCH_CLASS, $class) but ignores the $args
parameter, so classes with required constructor args won't be instantiated;
update the fetchAll call in the fetchObjects method to pass $args as the third
argument (i.e., fetchAll(PDO::FETCH_CLASS, $class, $args)) so constructor
arguments are forwarded just like fetchObject($class, $args).
Summary
#[DbQuery(factory: ...)]factories.PostQueryInterfaceresult wrapper support.Scope
PostQueryContextfor DML metadata.AffectedRows/InsertedRowsupport for a separate explicit metadata fixture design if needed.Tests
composer testscomposer crccomposer validate --strictSummary by CodeRabbit
New Features
Documentation
Tests
Chores
Bug Fixes