66
77use DomainException ;
88use ReflectionClass ;
9+ use ReflectionNamedType ;
910use ReflectionProperty ;
11+ use ReflectionUnionType ;
1012
1113use function class_exists ;
14+ use function is_array ;
15+ use function is_bool ;
16+ use function is_float ;
17+ use function is_int ;
18+ use function is_numeric ;
1219use function is_object ;
20+ use function is_scalar ;
21+ use function is_string ;
1322
1423/** Creates and manipulates entity objects using Style-based naming conventions */
1524class EntityFactory
@@ -47,14 +56,26 @@ public function createByName(string $name): object
4756
4857 public function set (object $ entity , string $ prop , mixed $ value ): void
4958 {
50- $ mirror = $ this ->reflectProperties ($ entity ::class)[$ prop ] ?? null ;
59+ $ styledProp = $ this ->style ->styledProperty ($ prop );
60+ $ mirror = $ this ->reflectProperties ($ entity ::class)[$ styledProp ] ?? null ;
5161
52- $ mirror ?->setValue($ entity , $ value );
62+ if ($ mirror === null ) {
63+ return ;
64+ }
65+
66+ $ coerced = $ this ->coerce ($ mirror , $ value );
67+
68+ if ($ coerced === null && !($ mirror ->getType ()?->allowsNull() ?? false )) {
69+ return ;
70+ }
71+
72+ $ mirror ->setValue ($ entity , $ coerced );
5373 }
5474
5575 public function get (object $ entity , string $ prop ): mixed
5676 {
57- $ mirror = $ this ->reflectProperties ($ entity ::class)[$ prop ] ?? null ;
77+ $ styledProp = $ this ->style ->styledProperty ($ prop );
78+ $ mirror = $ this ->reflectProperties ($ entity ::class)[$ styledProp ] ?? null ;
5879
5980 if ($ mirror === null || !$ mirror ->isInitialized ($ entity )) {
6081 return null ;
@@ -71,20 +92,20 @@ public function get(object $entity, string $prop): mixed
7192 public function extractColumns (object $ entity ): array
7293 {
7394 $ cols = $ this ->extractProperties ($ entity );
95+ $ relations = $ this ->detectRelationProperties ($ entity ::class);
7496
7597 foreach ($ cols as $ key => $ value ) {
76- if (!is_object ( $ value )) {
98+ if (!isset ( $ relations [ $ key ] )) {
7799 continue ;
78100 }
79101
80- if ($ this ->style ->isRelationProperty ($ key )) {
81- $ fk = $ this ->style ->remoteIdentifier ($ key );
102+ $ fk = $ this ->style ->remoteIdentifier ($ key );
103+
104+ if (is_object ($ value )) {
82105 $ cols [$ fk ] = $ this ->get ($ value , $ this ->style ->identifier ($ key ));
83- unset($ cols [$ key ]);
84- } else {
85- $ table = $ this ->style ->remoteFromIdentifier ($ key ) ?? $ key ;
86- $ cols [$ key ] = $ this ->get ($ value , $ this ->style ->identifier ($ table ));
87106 }
107+
108+ unset($ cols [$ key ]);
88109 }
89110
90111 return $ cols ;
@@ -121,6 +142,25 @@ public function hydrate(object $source, string $entityName): object
121142 return $ entity ;
122143 }
123144
145+ /** @return array<string, true> */
146+ private function detectRelationProperties (string $ class ): array
147+ {
148+ $ relations = [];
149+
150+ foreach ($ this ->reflectProperties ($ class ) as $ name => $ prop ) {
151+ $ type = $ prop ->getType ();
152+ $ types = $ type instanceof ReflectionUnionType ? $ type ->getTypes () : ($ type !== null ? [$ type ] : []);
153+ foreach ($ types as $ t ) {
154+ if ($ t instanceof ReflectionNamedType && !$ t ->isBuiltin ()) {
155+ $ relations [$ name ] = true ;
156+ break ;
157+ }
158+ }
159+ }
160+
161+ return $ relations ;
162+ }
163+
124164 /** @return ReflectionClass<object> */
125165 private function reflectClass (string $ class ): ReflectionClass
126166 {
@@ -144,4 +184,85 @@ private function reflectProperties(string $class): array
144184
145185 return $ this ->propertyCache [$ class ];
146186 }
187+
188+ private function coerce (ReflectionProperty $ prop , mixed $ value ): mixed
189+ {
190+ $ type = $ prop ->getType ();
191+
192+ if ($ type === null ) {
193+ throw new DomainException (
194+ 'Property ' . $ prop ->getDeclaringClass ()->getName () . '::$ ' . $ prop ->getName ()
195+ . ' must have a type declaration ' ,
196+ );
197+ }
198+
199+ if ($ value === null ) {
200+ return $ type ->allowsNull () ? null : $ value ;
201+ }
202+
203+ if ($ type instanceof ReflectionNamedType) {
204+ return $ this ->exactMatch ($ type , $ value ) ?? $ this ->coerceToNamedType ($ type , $ value );
205+ }
206+
207+ if ($ type instanceof ReflectionUnionType) {
208+ $ members = [];
209+ foreach ($ type ->getTypes () as $ member ) {
210+ if (!($ member instanceof ReflectionNamedType)) {
211+ continue ;
212+ }
213+
214+ $ members [] = $ member ;
215+ }
216+
217+ // Pass 1: exact type match (no lossy casts)
218+ foreach ($ members as $ member ) {
219+ $ result = $ this ->exactMatch ($ member , $ value );
220+ if ($ result !== null ) {
221+ return $ result ;
222+ }
223+ }
224+
225+ // Pass 2: lossy coercion (numeric string → int, scalar → string, etc.)
226+ foreach ($ members as $ member ) {
227+ $ result = $ this ->coerceToNamedType ($ member , $ value );
228+ if ($ result !== null ) {
229+ return $ result ;
230+ }
231+ }
232+ }
233+
234+ return null ;
235+ }
236+
237+ /** Accept value only if it already matches the type without any conversion */
238+ private function exactMatch (ReflectionNamedType $ type , mixed $ value ): mixed
239+ {
240+ $ name = $ type ->getName ();
241+
242+ return match (true ) {
243+ $ name === 'mixed ' => $ value ,
244+ $ name === 'int ' && is_int ($ value ) => $ value ,
245+ $ name === 'float ' && is_float ($ value ) => $ value ,
246+ $ name === 'string ' && is_string ($ value ) => $ value ,
247+ $ name === 'bool ' && is_bool ($ value ) => $ value ,
248+ $ name === 'array ' && is_array ($ value ) => $ value ,
249+ is_object ($ value ) && $ value instanceof $ name => $ value ,
250+ default => null ,
251+ };
252+ }
253+
254+ /** Accept value with lossy coercion (e.g. numeric string → int) */
255+ private function coerceToNamedType (ReflectionNamedType $ type , mixed $ value ): mixed
256+ {
257+ $ name = $ type ->getName ();
258+
259+ return match (true ) {
260+ $ name === 'mixed ' => $ value ,
261+ $ name === 'int ' && is_string ($ value ) && is_numeric ($ value ) => (int ) $ value ,
262+ $ name === 'float ' && is_int ($ value ) => (float ) $ value ,
263+ $ name === 'float ' && is_string ($ value ) && is_numeric ($ value ) => (float ) $ value ,
264+ $ name === 'string ' && is_scalar ($ value ) => (string ) $ value ,
265+ default => null ,
266+ };
267+ }
147268}
0 commit comments