Repository navigation
Conversation
Resolve a class-level Source through the configured annotations adapter before falling back to the uncamelized model name. Support positional and named table arguments while preserving explicit sources and table prefixes. Add regression coverage for both readers, optional DI services, per-class caching, column metadata, and SQLite persistence and queries. Assisted-by: Codex
Assisted-by: Codex
niden
left a comment
There was a problem hiding this comment.
Very nice fix and thank you for this. The logic i s correct but you missed the point where this starts running for every single model.
Looking forward to your fixes!
Only consult class attributes when the annotations service uses AttributesReader. Extract fallback resolution to a private method so the default docblock reader and Introspection models retain their behavior. Cover malformed docblocks, unannotated subclasses, and inherited explicit sources. Align test names and author/since tags with the repository style, clarify the Source API documentation, and shorten the changelog entry. Assisted-by: Codex
Exercise attribute-based persistence and PHQL with Introspection as well as Annotations, and assert that source resolution retains the configured metadata strategy. Cover the opt-in behavior of an AttributesReader subclass. Assisted-by: Codex
|
Thanks for the review, @niden! All six comments have been addressed:
Additional tests cover both metadata strategies and an Ready for another review. Thank you! |
niden
left a comment
There was a problem hiding this comment.
Thank you for the quick reply!
Second pass, mostly defensive checks and one coupling refactoring. This should be ready after that.
| { | ||
| parent::setUp(); | ||
|
|
||
| $this->container = new FactoryDefault(); |
There was a problem hiding this comment.
Just to be on the safe side, can you add a Di::reset() before this line. It will shield us from any other test before this, being left without its DI reset.
| let table = sourceAnnotation->getArgument(0); | ||
| } | ||
|
|
||
| if typeof table === "string" { |
There was a problem hiding this comment.
Add a check for when the table is also empty. A Source attribute with no name will make the table an empty array. Even better, throw an exception if the table name is empty.
Move table extraction and validation into Source::getTable(). Reject invalid annotations services and missing, empty or non-string Source table names with dedicated exceptions. Guard non-object services before Zephir's instanceof check to avoid warnings. Preserve AttributesReader opt-in, explicit-source precedence and fallback behavior. Cover custom adapters, invalid values, retry after failure and malformed-attribute overrides, reset DI during test setup, and keep private methods in alphabetical order. Assisted-by: Codex
Explain that missing, empty and non-string Source table names raise InvalidSourceTable during source resolution, matching phalcon/cphalcon#17742. Assisted-by: Codex
|
Thanks for the second review, @niden! All five follow-up comments are addressed:
Regression tests also cover custom adapters, explicit-source precedence over malformed attributes, and retry after an invalid service. All 77 targeted unit/SQLite tests pass in randomized order with The companion documentation is updated in phalcon/documentation#351, now in the 5.24 guides. Ready for another review. Thank you! |
niden
left a comment
There was a problem hiding this comment.
Round 3. Thank you for all the fixes and the quick turnaround. I think the first issue is the most pressing because it is a potential BC change, the rest are just tidying up.
|
|
||
| let annotations = <AnnotationsAdapterInterface> container->getShared("annotations"); | ||
|
|
||
| if typeof annotations !== "object" || !(annotations instanceof AnnotationsAdapterInterface) { |
There was a problem hiding this comment.
I think we have an issue here.
Some apps register a service that is not a Phalcon adapter as annotations, for example a Doctrine reader. In these apps, each model without setSource() now throws InvalidAnnotationsService. Before the PR, these apps worked with the default Introspection strategy. Only the annotations router and the Annotations metadata strategy read this service, and only when the app selects them. Now the manager reads it for every model. This conflicts with the opt-in rule from round 1.
I did ask for the throw and I should have thought about this issue beforehand so apologies. I think the best way to avoid BC changes and get what we need is to return the inferred source when the service is not an AdapterInterface (it counts as "not opted in"). Then we can remove the InvalidAnnotationsService.
| throw new InvalidAnnotationsService(); | ||
| } | ||
|
|
||
| if !(annotations->getReader() instanceof AttributesReader) { |
There was a problem hiding this comment.
It seems like we are fixing one thing another comes up. With this, now the Manager knows about the opt-in rule i.e. the AttributesReader which increases coupling again.
We can move the reader check and the lookup into Source, say Source::getTableFromAdapter(<AdapterInterface> adapter, string className) -> string | null. The manager will keep only the DI lookup and will know nothing other than Source.
| return source; | ||
| } | ||
|
|
||
| let classAnnotations = annotations->get(get_class(model))->getClassAnnotations(); |
There was a problem hiding this comment.
Performance related here: Reflection.
There is no real winner here if we want to introduce this - the cost though has to be highlighted. Some apps use AttributesReader with Introspection, for example apps that use attributes only for routing. In these apps, get() now reads the attributes of the class, its constants, its properties and all its methods, including the inherited Phalcon\Mvc\Model methods. With the Memory adapter, this occurs once per model class on each request. Before the PR, this cost was zero. With the Annotations strategy there is no extra cost, because that strategy already reads the same cached data.
We need to make a change in the documentation to advise developers to use a persistent adapter such as Apcu or Stream when using the AttributesReader. Memory carries the reflection cost.
| } | ||
|
|
||
| if typeof table !== "string" || table === "" { | ||
| throw new InvalidSourceTable(); |
There was a problem hiding this comment.
This exception travels upwards. As such it will not be caught if my mechanism as a developer is to catch the mvc\model\exception (most common case). Wrap the code in the manager with a try catch and re-throw it as model\exception, using this exception in $previous.
| let table = sourceAnnotation->getArgument(0); | ||
| } | ||
|
|
||
| if typeof table !== "string" || table === "" { |
There was a problem hiding this comment.
Can we use trim(table) === "" to shield from a table name with only spaces? Rare, very edge, agreed but it costs very little to shield from it.
| { | ||
| public function __construct() | ||
| { | ||
| parent::__construct("Source table name must be a non-empty string"); |
There was a problem hiding this comment.
Can we inject the $table here as a parameter and use it in the messge? A bit more info.
Hello!
Fixes #17741.
In raising this pull request, I confirm the following:
#[Source(table: 'co_customers')]is read byAttributesReader, but the ORM ignores it and usescustomersfor aCustomersmodel. This causes queries to target the wrong table unless the model also callssetSource().Resolve the class-level
Sourceattribute only when the container's annotations service explicitly usesAttributesReader(or a subclass). A privategetDefaultModelSource()method checks the service interface and that opt-in, then delegates table extraction toSource::getTable(Collection). Both positional and namedtablearguments are supported. ExplicitsetSource()/setModelSource()values retain priority, the existing per-class source cache stores the result, and the manager applies its current table prefix on each lookup.An invalid
annotationsservice raisesInvalidAnnotationsService, including when the service returns a non-object value. ASourceattribute with a missing, empty, or non-string table raisesInvalidSourceTable; a collection withoutSourcereturns null so the manager keeps the inferred-name fallback. An explicit source bypasses attribute lookup and validation.The default PHPDoc reader is not invoked for source resolution, so existing
Introspectionmodels keep working even when their unrelated docblocks are rejected by the annotation parser. PHPDoc@Sourceremains ignored. Missing DI/annotations services and classes withoutSourceretain the inferred-name fallback; foreign attributes namedSourcedo not override it.Attributes are not inherited: a subclass without its own
Sourcegets the table inferred from the subclass name. An inheritedinitialize()that callssetSource()still explicitly sets the child's source. Unit and SQLite tests cover both cases, anAttributesReadersubclass, attribute-based persistence with both metadata strategies, and an Introspection model with malformed docblocks. Table resolution retains the configured metadata strategy. The Source API docblock includes a usage example; applications still configureAttributesReaderon their annotations adapter.Validation on PHP 8.5.10, Zephir 1.7.0 and PHPUnit 10.5.66:
--fail-on-warning.git diff --checkpasses.Regression tests cover arbitrary adapters implementing AdapterInterface, invalid service types, retry after replacing an invalid shared service, and explicit-source priority over malformed attributes. The scalar-service cases reproduced five PHP warnings on the previous guard before adding the object-type check.
The full suites requiring Redis/Memcached services, MySQL/PostgreSQL, and the remaining PHP-version matrix were not run locally.
Thanks!