diff --git a/.github/expected-missing-return-types.diff b/.github/expected-missing-return-types.diff index 7de03a4df42d9..66d8794db0f2e 100644 --- a/.github/expected-missing-return-types.diff +++ b/.github/expected-missing-return-types.diff @@ -521,3 +521,13 @@ diff --git a/src/Symfony/Contracts/Translation/LocaleAwareInterface.php b/src/Sy + public function setLocale(string $locale): void; /** +diff --git a/src/Symfony/Contracts/Translation/TranslatorTrait.php b/src/Symfony/Contracts/Translation/TranslatorTrait.php +--- a/src/Symfony/Contracts/Translation/TranslatorTrait.php ++++ b/src/Symfony/Contracts/Translation/TranslatorTrait.php +@@ -26,5 +26,5 @@ trait TranslatorTrait + * @return void + */ +- public function setLocale(string $locale) ++ public function setLocale(string $locale): void + { + $this->locale = $locale; diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 2ad2ebb54377f..623014f360c99 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -107,7 +107,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install system dependencies run: | @@ -190,7 +190,7 @@ jobs: # sudo rm -rf .phpunit # [ -d .phpunit.bak ] && mv .phpunit.bak .phpunit - - uses: marceloprado/has-changed-path@v1 + - uses: marceloprado/has-changed-path@v1.0.1 id: changed-translation-files with: paths: src/**/Resources/translations/*.xlf diff --git a/.github/workflows/intl-data-tests.yml b/.github/workflows/intl-data-tests.yml index a3cbdb15fb207..01401fedc232f 100644 --- a/.github/workflows/intl-data-tests.yml +++ b/.github/workflows/intl-data-tests.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install system dependencies run: | diff --git a/.github/workflows/package-tests.yml b/.github/workflows/package-tests.yml index dd2bf4c61d915..953a06586c644 100644 --- a/.github/workflows/package-tests.yml +++ b/.github/workflows/package-tests.yml @@ -14,7 +14,7 @@ jobs: runs-on: Ubuntu-20.04 steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Fetch branch from where the PR started run: git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* diff --git a/.github/workflows/phpunit-bridge.yml b/.github/workflows/phpunit-bridge.yml index 776ad2ee03f33..fd169dfae782d 100644 --- a/.github/workflows/phpunit-bridge.yml +++ b/.github/workflows/phpunit-bridge.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index 46072b51eb330..ce367998fcb74 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -30,12 +30,12 @@ jobs: coverage: none - name: Checkout target branch - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.base_ref }} - name: Checkout PR - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install dependencies run: | diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 339ba1437e89b..e08273191c5d0 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -39,7 +39,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 2 diff --git a/CHANGELOG-6.3.md b/CHANGELOG-6.3.md index b98c8dfc740c5..9d3308e979be8 100644 --- a/CHANGELOG-6.3.md +++ b/CHANGELOG-6.3.md @@ -7,6 +7,40 @@ in 6.3 minor versions. To get the diff for a specific change, go to https://github.com/symfony/symfony/commit/XXX where XXX is the change hash To get the diff between two versions, go to https://github.com/symfony/symfony/compare/v6.3.0...v6.3.1 +* 6.3.4 (2023-08-26) + + * bug #51475 [Serializer] Fix union of enum denormalization (mtarld) + * bug #51474 [Serializer] Fix wrong InvalidArgumentException thrown (mtarld) + * bug #51494 Fixed attachment base64 content string in MailerSendApiTransport (pavelwitassek) + * bug #51350 [Security] Prevent creating session in stateless firewalls (Seb33300) + * bug #51104 [Security] Fix loading user from UserBadge (guillaumesmo) + * bug #51473 [VarDumper] Fix managing collapse state in CliDumper (nicolas-grekas) + * bug #51369 [Serializer] Fix deserializing object collection properties (X-Coder264) + * bug #51399 [Serializer] Fix deserializing of nested snake_case attributes using CamelCaseToSnakeCaseNameConverter (Victor-Truhanovich) + * bug #51456 [Serializer] Fix serialized name with groups during denormalization (mtarld) + * bug #51445 [Security] FormLoginAuthenticator: fail for non-string password (dmaicher) + * bug #51424 [HttpFoundation] Fix base URI detection on IIS with UrlRewriteModule (derrabus) + * bug #51396 [HttpKernel] Fix missing Request in RequestStack for StreamedResponse (Ismail Turan) + * bug #51378 [Console] avoid multiple new line when message already ends with a new line in section output (joelwurtz) + * bug #51336 [Notifier] [Pushover] Fix invalid method call + improve exception message (ahmedghanem00) + * bug #51345 [AssetMapper] Fixing bug where a circular exception could be thrown while making error message (weaverryan) + * bug #48840 [Validator] Dump Valid constraints on debug command (macintoshplus) + * bug #51223 [Console] Fix linewraps in `OutputFormatter` (maxbeckers) + * bug #51307 [DependencyInjection] fix dump xml with array/object/enum default value (Jean-Beru) + * bug #51355 [Console] fix section output when multiples section with max height (joelwurtz) + * bug #51359 [Security] Fix error with lock_factory in login_throttling (BaptisteContreras) + * bug #51326 [FrameworkBundle] Fix xsd for handle-all-throwables (Jean-Beru) + * bug #51328 [Messenger] Always return bool from messenger amqp connection nack (Danielss89) + * bug #51295 [Mailer] update Brevo SMTP host (bastien-wink) + * bug #51301 [FrameworkBundle] add missing default-doctrine-dbal-provider cache pool attribute to XSD (xabbuh) + * bug #51296 [Process] Fix silencing `wait` when using a sigchild-enabled binary (nicolas-grekas) + * bug #51251 [DependencyInjection] Do not add `return` in `LazyClosure` when return type of closure is `void` (ruudk) + * bug #51219 [DependencyInjection][HttpKernel] Fix using `#[AutowireCallable]` with controller arguments (HypeMC) + * bug #51201 [Workflow] fix MermaidDumper when place contains special char (lyrixx) + * bug #49195 [Crawler] Fix regression where cdata nodes will return empty string (NanoSector) + * bug #51061 [DoctrineBridge] Bugfix - Allow to remove LazyLoaded listeners by object (VincentLanglet) + * bug #51190 [Clock] load function only if not loaded before (xabbuh) + * 6.3.3 (2023-07-31) * bug #51178 [Finder] Revert "Fix children condition in ExcludeDirectoryFilterIterator" (derrabus) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4b2f33954ad21..2346e07cbd6ad 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -12,8 +12,8 @@ The Symfony Connect username in parenthesis allows to get more information - Bernhard Schussek (bschussek) - Tobias Schultze (tobion) - Thomas Calvet (fancyweb) - - Jérémy DERUSSÉ (jderusse) - Grégoire Pineau (lyrixx) + - Jérémy DERUSSÉ (jderusse) - Wouter de Jong (wouterj) - Maxime Steinhausser (ogizanagi) - Christophe Coevoet (stof) @@ -21,47 +21,48 @@ The Symfony Connect username in parenthesis allows to get more information - Jordi Boggiano (seldaek) - Roland Franssen (ro0) - Victor Berchet (victor) + - Oskar Stark (oskarstark) - Javier Eguiluz (javier.eguiluz) - Yonel Ceruto (yonelceruto) - Ryan Weaver (weaverryan) - Tobias Nyholm (tobias) - - Oskar Stark (oskarstark) - Johannes S (johannes) - Jakub Zalas (jakubzalas) - Kris Wallsmith (kriswallsmith) + - Alexandre Daubois (alexandre-daubois) + - Jules Pietri (heah) - Hugo Hamon (hhamon) - Hamza Amrouche (simperfit) - Samuel ROZE (sroze) - - Jules Pietri (heah) + - Jérôme Tamarelle (gromnan) - Pascal Borreli (pborreli) - Romain Neutron + - Kevin Bond (kbond) - Joseph Bielawski (stloyd) - - Alexandre Daubois (alexandre-daubois) - Drak (drak) - Abdellatif Ait boudad (aitboudad) - - Jérôme Tamarelle (gromnan) - Jan Schädlich (jschaedl) - Lukas Kahwe Smith (lsmith) - - Kevin Bond (kbond) + - HypeMC (hypemc) - Martin Hasoň (hason) - Jeremy Mikola (jmikola) - Jean-François Simon (jfsimon) - Benjamin Eberlei (beberlei) - Igor Wiedler - - HypeMC (hypemc) - Antoine Lamirault (alamirault) - Valentin Udaltsov (vudaltsov) - Vasilij Duško (staff) - Matthias Pigulla (mpdude) - - Laurent VOULLEMIER (lvo) - Gabriel Ostrolucký (gadelat) + - Laurent VOULLEMIER (lvo) - Antoine Makdessi (amakdessi) - Mathieu Lechat (mat_the_cat) - Pierre du Plessis (pierredup) - Grégoire Paris (greg0ire) - Jonathan Wage (jwage) - - Titouan Galopin (tgalopin) - David Maicher (dmaicher) + - Titouan Galopin (tgalopin) + - Vincent Langlet (deviling) - Alexander Schranz (alexander-schranz) - Gábor Egyed (1ed) - Mathieu Santostefano (welcomattic) @@ -74,7 +75,6 @@ The Symfony Connect username in parenthesis allows to get more information - stealth35 ‏ (stealth35) - Alexander Mols (asm89) - Francis Besset (francisbesset) - - Vincent Langlet (deviling) - Vasilij Dusko | CREATION - Bulat Shakirzyanov (avalanche123) - Iltar van der Berg @@ -82,27 +82,29 @@ The Symfony Connect username in parenthesis allows to get more information - Mathieu Piot (mpiot) - Saša Stamenković (umpirsky) - Alex Pott + - Gary PEGEOT (gary-p) - Guilhem N (guilhemn) - Vladimir Reznichenko (kalessil) - Sarah Khalil (saro0h) - Tomas Norkūnas (norkunas) + - Ruud Kamphuis (ruudk) - Konstantin Kudryashov (everzet) - Bilal Amarni (bamarni) - Eriksen Costa - - Ruud Kamphuis (ruudk) - Florin Patan (florinpatan) - Konstantin Myakshin (koc) - Peter Rehm (rpet) - Henrik Bjørnskov (henrikbjorn) - David Buchmann (dbu) + - Allison Guilhem (a_guilhem) - Massimiliano Arione (garak) + - Mathias Arlaud (mtarld) - Andrej Hudec (pulzarraider) - Julien Falque (julienfalque) + - Fran Moreno (franmomu) - Jáchym Toušek (enumag) - Douglas Greenshields (shieldo) - - Mathias Arlaud (mtarld) - Christian Raue - - Fran Moreno (franmomu) - Graham Campbell (graham) - Michel Weimerskirch (mweimerskirch) - Eric Clemmons (ericclemmons) @@ -116,14 +118,13 @@ The Symfony Connect username in parenthesis allows to get more information - Henrik Westphal (snc) - Dariusz Górecki (canni) - Maxime Helias (maxhelias) - - Gary PEGEOT (gary-p) - Ener-Getick - Tugdual Saunier (tucksaun) + - Yanick Witschi (toflar) - Rokas Mikalkėnas (rokasm) - Sebastiaan Stok (sstok) - Jérôme Vasseur (jvasseur) - Ion Bazan (ionbazan) - - Yanick Witschi (toflar) - Lee McDermott - Brandon Turner - Luis Cordova (cordoval) @@ -135,26 +136,28 @@ The Symfony Connect username in parenthesis allows to get more information - John Wards (johnwards) - Dariusz Ruminski - Lars Strojny (lstrojny) + - Joel Wurtz (brouznouf) - Antoine Hérault (herzult) - Konstantin.Myakshin - Arman Hosseini (arman) + - Frank A. Fiebig (fafiebig) - gnito-org - Saif Eddin Gmati (azjezz) - Simon Berger - Arnaud Le Blanc (arnaud-lb) + - Hubert Lenoir (hubert_lenoir) - Maxime STEINHAUSSER - Peter Kokot (maastermedia) - jeremyFreeAgent (jeremyfreeagent) - Ahmed TAILOULOUTE (ahmedtai) - - Joel Wurtz (brouznouf) - Tim Nagel (merk) - - Allison Guilhem (a_guilhem) - Andreas Braun - Teoh Han Hui (teohhanhui) - YaFou - Chris Wilkinson (thewilkybarkid) - Brice BERNARD (brikou) - Roman Martinuk (a2a4) + - Jacob Dreesen (jdreesen) - Gregor Harlan (gharlan) - Christopher Hertel (chertel) - Baptiste Clavié (talus) @@ -163,7 +166,6 @@ The Symfony Connect username in parenthesis allows to get more information - marc.weistroff - lenar - Jesse Rushlow (geeshoe) - - Jacob Dreesen (jdreesen) - Théo FIDRY - Jeroen Spee (jeroens) - Michael Babker (mbabker) @@ -183,7 +185,6 @@ The Symfony Connect username in parenthesis allows to get more information - Richard van Laak (rvanlaak) - Nicolas Philippe (nikophil) - Paráda József (paradajozsef) - - Hubert Lenoir (hubert_lenoir) - Alessandro Lai (jean85) - Alexander Schwenn (xelaris) - Fabien Pennequin (fabienpennequin) @@ -200,6 +201,7 @@ The Symfony Connect username in parenthesis allows to get more information - Chi-teck - Hugo Monteiro (monteiro) - Baptiste Leduc (korbeil) + - Antonio Pauletich (x-coder264) - Marco Pivetta (ocramius) - Robert Schönthal (digitalkaoz) - Michael Voříšek @@ -223,7 +225,6 @@ The Symfony Connect username in parenthesis allows to get more information - Guilliam Xavier - David Prévot - Sergey (upyx) - - Antonio Pauletich (x-coder264) - Timo Bakx (timobakx) - Juti Noppornpitak (shiroyuki) - Joe Bennett (kralos) @@ -236,6 +237,7 @@ The Symfony Connect username in parenthesis allows to get more information - Daniel Gomes (danielcsgomes) - Michael Käfer (michael_kaefer) - Hidenori Goto (hidenorigoto) + - Jonathan Scheiber (jmsche) - Albert Casademont (acasademont) - Arnaud Kleinpeter (nanocom) - Guilherme Blanco (guilhermeblanco) @@ -248,6 +250,7 @@ The Symfony Connect username in parenthesis allows to get more information - Jannik Zschiesche - Rafael Dohms (rdohms) - George Mponos (gmponos) + - Thomas Landauer (thomas-landauer) - Fritz Michael Gschwantner (fritzmg) - Aleksandar Jakovljevic (ajakov) - jwdeitch @@ -256,6 +259,7 @@ The Symfony Connect username in parenthesis allows to get more information - Fabien Bourigault (fbourigault) - soyuka - Jérémy Derussé + - Maximilian Beckers (maxbeckers) - Sébastien Alfaiate (seb33300) - Florent Mata (fmata) - mcfedr (mcfedr) @@ -269,14 +273,12 @@ The Symfony Connect username in parenthesis allows to get more information - Niels Keurentjes (curry684) - Vyacheslav Pavlov - Richard Shank (iampersistent) - - Thomas Landauer (thomas-landauer) - Romain Monteil (ker0x) - Andre Rømcke (andrerom) - Dmitrii Poddubnyi (karser) - Rouven Weßling (realityking) - BoShurik - Zmey - - Maximilian Beckers (maxbeckers) - Clemens Tolboom - Oleg Voronkovich - Alan Poulain (alanpoulain) @@ -297,7 +299,6 @@ The Symfony Connect username in parenthesis allows to get more information - Amal Raghav (kertz) - Jonathan Ingram - Artur Kotyrba - - Jonathan Scheiber (jmsche) - Tyson Andre - GDIBass - Samuel NELA (snela) @@ -375,7 +376,6 @@ The Symfony Connect username in parenthesis allows to get more information - Vladyslav Loboda - Pierre Minnieur (pminnieur) - Kyle - - Frank A. Fiebig (fafiebig) - Dominique Bongiraud - Hidde Wieringa (hiddewie) - Dane Powell @@ -421,6 +421,7 @@ The Symfony Connect username in parenthesis allows to get more information - Mantis Development - Pablo Lozano (arkadis) - quentin neyrat (qneyrat) + - Florent Morselli (spomky_) - Antonio Jose Cerezo (ajcerezo) - Marcin Szepczynski (czepol) - Lescot Edouard (idetox) @@ -456,6 +457,7 @@ The Symfony Connect username in parenthesis allows to get more information - Marcin Michalski (marcinmichalski) - Roman Ring (inori) - Xavier Montaña Carreras (xmontana) + - Samaël Villette (samadu61) - Tarmo Leppänen (tarlepp) - AnneKir - Tobias Weichart @@ -514,7 +516,6 @@ The Symfony Connect username in parenthesis allows to get more information - Bernd Stellwag - Philippe SEGATORI (tigitz) - Frank de Jonge - - Florent Morselli (spomky_) - Chris Tanaskoski - julien57 - Renan (renanbr) @@ -577,13 +578,13 @@ The Symfony Connect username in parenthesis allows to get more information - Marc Morera (mmoreram) - Gabor Toth (tgabi333) - realmfoo + - Dmitriy Derepko - Thomas Tourlourat (armetiz) - Gasan Guseynov (gassan) - Andrey Esaulov (andremaha) - Grégoire Passault (gregwar) - Jerzy Zawadzki (jzawadzki) - Ismael Ambrosi (iambrosi) - - Samaël Villette (samadu61) - Saif Eddin G - Emmanuel BORGES (eborges78) - siganushka (siganushka) @@ -609,6 +610,8 @@ The Symfony Connect username in parenthesis allows to get more information - Terje Bråten - Gennadi Janzen - James Hemery + - Ben Roberts (benr77) + - Benjamin (yzalis) - Egor Taranov - Philippe Segatori - Adrian Nguyen (vuphuong87) @@ -793,6 +796,7 @@ The Symfony Connect username in parenthesis allows to get more information - arai - Mouad ZIANI (mouadziani) - Daniel Tschinder + - Roland Franssen :) - Diego Agulló (aeoris) - Tomasz Ignatiuk - vladimir.reznichenko @@ -839,10 +843,10 @@ The Symfony Connect username in parenthesis allows to get more information - Paulo Ribeiro (paulo) - Marc Laporte - Michał Jusięga - - Dmitriy Derepko - Sebastian Paczkowski (sebpacz) - Dragos Protung (dragosprotung) - Thiago Cordeiro (thiagocordeiro) + - wicliff wolda (wickedone) - Julien Maulny - Brian King - Wouter van der Loop (toppy-hennie) @@ -876,7 +880,6 @@ The Symfony Connect username in parenthesis allows to get more information - Ivan Nikolaev (destillat) - Xavier Leune (xleune) - Matthieu Calie (matth--) - - Ben Roberts (benr77) - Benjamin Georgeault (wedgesama) - Joost van Driel (j92) - ampaze @@ -897,6 +900,7 @@ The Symfony Connect username in parenthesis allows to get more information - Gwendolen Lynch - Kamil Kokot (pamil) - Seb Koelen + - Guillaume Aveline - Christoph Mewes (xrstf) - Vitaliy Tverdokhlib (vitaliytv) - Ariel Ferrandini (aferrandini) @@ -1000,7 +1004,6 @@ The Symfony Connect username in parenthesis allows to get more information - Krzysztof Piasecki (krzysztek) - Lenard Palko - Nils Adermann (naderman) - - Roland Franssen :) - Gábor Fási - Nate (frickenate) - Sander De la Marche (sanderdlm) @@ -1089,6 +1092,7 @@ The Symfony Connect username in parenthesis allows to get more information - Dmitry Simushev - Grégoire Hébert (gregoirehebert) - alcaeus + - Ahmed Ghanem (ahmedghanem00) - Fred Cox - Iliya Miroslavov Iliev (i.miroslavov) - Safonov Nikita (ns3777k) @@ -1207,7 +1211,6 @@ The Symfony Connect username in parenthesis allows to get more information - Alex Bogomazov (alebo) - Claus Due (namelesscoder) - aaa2000 (aaa2000) - - Guillaume Aveline - Alexandru Patranescu - Arkadiusz Rzadkowolski (flies) - Oksana Kozlova (oksanakozlova) @@ -1236,7 +1239,6 @@ The Symfony Connect username in parenthesis allows to get more information - Tamás Nagy (t-bond) - Sergey Kolodyazhnyy (skolodyazhnyy) - umpirski - - Benjamin - Quentin de Longraye (quentinus95) - Chris Heng (gigablah) - Oleksii Svitiashchuk @@ -1403,6 +1405,7 @@ The Symfony Connect username in parenthesis allows to get more information - radar3301 - Aleksey Prilipko - Andrew Berry + - Sylvain BEISSIER (sylvain-beissier) - Wybren Koelmans (wybren_koelmans) - Dmytro Dzubenko - victor-prdh @@ -1457,6 +1460,7 @@ The Symfony Connect username in parenthesis allows to get more information - Robert Fischer (sandoba) - Tarjei Huse (tarjei) - Besnik Br + - Issam Raouf (iraouf) - Michael Olšavský - Benny Born - Emirald Mateli @@ -1707,6 +1711,7 @@ The Symfony Connect username in parenthesis allows to get more information - Neil Ferreira - Julie Hourcade (juliehde) - Dmitry Parnas (parnas) + - Valtteri R (valtzu) - Christian Weiske - Maria Grazia Patteri - Sébastien COURJEAN @@ -1735,7 +1740,6 @@ The Symfony Connect username in parenthesis allows to get more information - Sergii Dolgushev (serhey) - Rein Baarsma (solidwebcode) - Stephen Lewis (tehanomalousone) - - wicliff wolda (wickedone) - Wim Molenberghs (wimm) - Loic Chardonnet - Ivan Menshykov @@ -1862,6 +1866,7 @@ The Symfony Connect username in parenthesis allows to get more information - Balazs Csaba - Bill Hance (billhance) - Douglas Reith (douglas_reith) + - Zbigniew Malcherczyk (ferror) - Harry Walter (haswalt) - Jeffrey Moelands (jeffreymoelands) - Jacques MOATI (jmoati) @@ -1918,6 +1923,7 @@ The Symfony Connect username in parenthesis allows to get more information - Jérémie Broutier - Success Go - Chris McGehee + - Bastien THOMAS - Benjamin Rosenberger - Vladyslav Startsev - Markus Klein @@ -2026,6 +2032,7 @@ The Symfony Connect username in parenthesis allows to get more information - Guillaume Gammelin - Valérian Galliat - d-ph + - MrMicky - Renan Taranto (renan-taranto) - Mateusz Żyła (plotkabytes) - Rikijs Murgs @@ -2056,6 +2063,7 @@ The Symfony Connect username in parenthesis allows to get more information - Sander Marechal - Franz Wilding (killerpoke) - Ferenczi Krisztian (fchris82) + - Simon André (simonandre) - Artyum Petrov - Oleg Golovakhin (doc_tr) - Icode4Food (icode4food) @@ -2081,6 +2089,7 @@ The Symfony Connect username in parenthesis allows to get more information - Chris de Kok - Andreas Kleemann (andesk) - Hubert Moreau (hmoreau) + - Brajk19 - Manuele Menozzi - Anton Babenko (antonbabenko) - Irmantas Šiupšinskas (irmantas) @@ -2119,6 +2128,7 @@ The Symfony Connect username in parenthesis allows to get more information - Pablo Borowicz - Ondřej Frei - Máximo Cuadros (mcuadros) + - Camille Baronnet - EXT - THERAGE Kevin - tamirvs - gauss @@ -2128,12 +2138,14 @@ The Symfony Connect username in parenthesis allows to get more information - Chris Tiearney - Oliver Hoff - Ole Rößner (basster) + - andersmateusz - Faton (notaf) - Tom Houdmont - mark burdett - Per Sandström (per) - Goran Juric - Laurent G. (laurentg) + - Jean-Baptiste Nahan - Nicolas Macherey - Asil Barkin Elik (asilelik) - Bhujagendra Ishaya @@ -2171,6 +2183,7 @@ The Symfony Connect username in parenthesis allows to get more information - Viktor Novikov (nowiko) - Paul Mitchum (paul-m) - Angel Koilov (po_taka) + - Yura Uvarov (zim32) - Dan Finnie - Ken Marfilla (marfillaster) - Max Grigorian (maxakawizard) @@ -2285,6 +2298,7 @@ The Symfony Connect username in parenthesis allows to get more information - Mehrdad - Eduardo García Sanz (coma) - fduch (fduch) + - Jan Walther (janwalther) - Takashi Kanemoto (ttskch) - David de Boer (ddeboer) - Eno Mullaraj (emullaraj) @@ -2317,6 +2331,7 @@ The Symfony Connect username in parenthesis allows to get more information - Derek Lambert (dlambert) - Mark Pedron (markpedron) - Peter Thompson (petert82) + - Ismail Turan - error56 - Felicitus - alexpozzi @@ -2361,6 +2376,7 @@ The Symfony Connect username in parenthesis allows to get more information - Sander Hagen - cilefen (cilefen) - Mo Di (modi) + - Victor Truhanovich (victor_truhanovich) - Pablo Schläpfer - Nikos Charalampidis - Xavier RENAUDIN @@ -2415,6 +2431,7 @@ The Symfony Connect username in parenthesis allows to get more information - Erika Heidi Reinaldo (erikaheidi) - Marc J. Schmidt (marcjs) - Sebastian Schwarz + - Flohw - karolsojko - Marco Jantke - Saem Ghani @@ -2628,6 +2645,7 @@ The Symfony Connect username in parenthesis allows to get more information - Gautier Deuette - Kirk Madera - Keith Maika + - izenin - Mephistofeles - Oleh Korneliuk - Hoffmann András @@ -2788,6 +2806,7 @@ The Symfony Connect username in parenthesis allows to get more information - Vincent Bouzeran - Grayson Koonce - Wissame MEKHILEF + - NanoSector - Romain Dorgueil - Christopher Parotat - Dennis Haarbrink @@ -3122,6 +3141,7 @@ The Symfony Connect username in parenthesis allows to get more information - Shrey Puranik - Lars Moelleken - dasmfm + - Baptiste CONTRERAS - Mathias Geat - Angel Fernando Quiroz Campos (angelfqc) - Arnaud Buathier (arnapou) @@ -3138,7 +3158,6 @@ The Symfony Connect username in parenthesis allows to get more information - Thomas Dutrion (theocrite) - Till Klampaeckel (till) - Tobias Weinert (tweini) - - Valtteri R (valtzu) - Wotre - goohib - Tom Counsell @@ -3221,6 +3240,7 @@ The Symfony Connect username in parenthesis allows to get more information - James Michael DuPont - Markus Tacker - Kasperki + - Daniel Strøm - Tammy D - Adrien Foulon - Ryan Rud @@ -3337,6 +3357,7 @@ The Symfony Connect username in parenthesis allows to get more information - Bram Tweedegolf (bram_tweedegolf) - Brandon Kelly (brandonkelly) - Choong Wei Tjeng (choonge) + - Bermon Clément (chou666) - Kousuke Ebihara (co3k) - Loïc Vernet (coil) - Christoph Vincent Schaefer (cvschaefer) @@ -3411,7 +3432,6 @@ The Symfony Connect username in parenthesis allows to get more information - Schuyler Jager (sjager) - Volker (skydiablo) - Julien Sanchez (sumbobyboys) - - Sylvain BEISSIER (sylvain-beissier) - Ron Gähler (t-ronx) - Guillermo Gisinger (t3chn0r) - Tom Newby (tomnewbyau) diff --git a/README.md b/README.md index bd66089ef554e..3bebfb77519c6 100644 --- a/README.md +++ b/README.md @@ -17,32 +17,19 @@ Installation Sponsor ------- -Symfony 6.3 is [backed][27] by +Symfony 6.4 is [backed][27] by - [SensioLabs][28] -- [Shopware][29] -- [Les-Tilleuls.coop][30] -- [basecom][31] +- [packagist.com][29] As the creator of Symfony, **SensioLabs** supports companies using Symfony, with an offering encompassing consultancy, expertise, services, training, and technical assistance to ensure the success of web application development projects. -**Shopware** offers you cutting-edge, highly adaptable ecommerce solutions trusted -by the world's most acclaimed brands. Create outstanding customer experiences, -innovate fast, and accelerate your growth in the ever-evolving space of digital -commerce. You decide how far you want to go, and we'll be by your side. +Private **Packagist.com** is a fast, reliable, and secure Composer repository for +your private packages. It mirrors all your open-source dependencies for better +availability and monitors them for security vulnerabilities. -**Les-Tilleuls.coop** is a team of 70+ Symfony experts who can help you design, -develop and fix your projects. We provide a wide range of professional services -including development, consulting, coaching, training and audits. We also are -highly skilled in JS, Go and DevOps. We are a worker cooperative! - -As a professional software service provider, **basecom** implements customized -solutions in the areas of e-commerce, PIM solutions and web portals. With their -experience and certified expertise, they have been one of the most renowned -Symfony specialists in Germany for many years. - -Help Symfony by [sponsoring][32] its development! +Help Symfony by [sponsoring][30] its development! Documentation ------------- @@ -106,7 +93,5 @@ and supported by [Symfony contributors][19]. [26]: https://symfony.com/book [27]: https://symfony.com/backers [28]: https://sensiolabs.com -[29]: https://www.shopware.com -[30]: https://les-tilleuls.coop -[31]: https://basecom.de -[32]: https://symfony.com/sponsor +[29]: https://packagist.com +[30]: https://symfony.com/sponsor diff --git a/UPGRADE-7.0.md b/UPGRADE-7.0.md index 1d8da62bfd778..9674437b55c19 100644 --- a/UPGRADE-7.0.md +++ b/UPGRADE-7.0.md @@ -202,6 +202,16 @@ FrameworkBundle * Remove the integration of Doctrine annotations, use native attributes instead * Remove `EnableLoggerDebugModePass`, use argument `$debug` of HttpKernel's `Logger` instead * Remove `AddDebugLogProcessorPass::configureLogger()`, use HttpKernel's `DebugLoggerConfigurator` instead + * Make the `framework.handle_all_throwables` config option default to `true` + * Make the `framework.php_errors.log` config option default to `true` + * Make the `framework.session.cookie_secure` config option default to `auto` + * Make the `framework.session.cookie_samesite` config option default to `lax` + * Make the `framework.session.handler_id` default to null if `save_path` is not set and to `session.handler.native_file` otherwise + * Make the `framework.uid.default_uuid_version` config option default to `7` + * Make the `framework.uid.time_based_uuid_version` config option default to `7` + * Make the `framework.validation.email_validation_mode` config option default to `html5` + * Remove the `framework.validation.enable_annotations` config option, use `framework.validation.enable_attributes` instead + * Remove the `framework.serializer.enable_annotations` config option, use `framework.serializer.enable_attributes` instead HttpFoundation -------------- @@ -452,6 +462,7 @@ TwigBundle * Remove option `twig.autoescape`; create a class that implements your escaping strategy (check `FileExtensionEscapingStrategy::guess()` for inspiration) and reference it using the `twig.autoescape_service` option instead + * Drop support for Twig 2 Validator --------- @@ -464,9 +475,11 @@ Validator * Remove `VALIDATION_MODE_LOOSE` from `Email` constraint, use `VALIDATION_MODE_HTML5` instead * Remove constraint `ExpressionLanguageSyntax`, use `ExpressionSyntax` instead * Remove Doctrine annotations support in favor of native attributes - * Remove the annotation reader parameter from the constructor signature of `AnnotationLoader` * Remove `ValidatorBuilder::setDoctrineAnnotationReader()` * Remove `ValidatorBuilder::addDefaultDoctrineAnnotationReader()` + * Remove `ValidatorBuilder::enableAnnotationMapping()`, use `ValidatorBuilder::enableAttributeMapping()` instead + * Remove `ValidatorBuilder::disableAnnotationMapping()`, use `ValidatorBuilder::disableAttributeMapping()` instead + * Remove `AnnotationLoader`, use `AttributeLoader` instead VarDumper --------- @@ -478,6 +491,7 @@ Workflow -------- * Require explicit argument when calling `Definition::setInitialPlaces()` + * `GuardEvent::getContext()` method has been removed. Method was not supposed to be called within guard event listeners as it always returned an empty array anyway. Yaml ---- diff --git a/composer.json b/composer.json index dcb2203ef1c29..f5fa2db394bd0 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,7 @@ "ext-xml": "*", "doctrine/event-manager": "^2", "doctrine/persistence": "^3.1", - "twig/twig": "^2.13|^3.0.4", + "twig/twig": "^3.0.4", "psr/cache": "^2.0|^3.0", "psr/clock": "^1.0", "psr/container": "^1.1|^2.0", @@ -134,6 +134,7 @@ "egulias/email-validator": "^2.1.10|^3.1|^4", "guzzlehttp/promises": "^1.4", "league/html-to-markdown": "^5.0", + "league/uri": "^6.5|^7.0", "masterminds/html5": "^2.7.2", "monolog/monolog": "^3.0", "nyholm/psr7": "^1.0", diff --git a/src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php b/src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php index 5fc6359d20170..75323645d8832 100644 --- a/src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php +++ b/src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php @@ -80,7 +80,6 @@ public function updateToken(string $series, #[\SensitiveParameter] string $token 'series' => ParameterType::STRING, ]; $updated = $this->conn->executeStatement($sql, $paramValues, $paramTypes); - if ($updated < 1) { throw new TokenNotFoundException('No token found.'); } diff --git a/src/Symfony/Bridge/Doctrine/Tests/DoctrineTestHelper.php b/src/Symfony/Bridge/Doctrine/Tests/DoctrineTestHelper.php index 6b5648a75a624..509d250e12afc 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/DoctrineTestHelper.php +++ b/src/Symfony/Bridge/Doctrine/Tests/DoctrineTestHelper.php @@ -59,10 +59,7 @@ public static function createTestConfiguration(): Configuration $config->setProxyNamespace('SymfonyTests\Doctrine'); $config->setMetadataDriverImpl(new AttributeDriver([__DIR__.'/../Tests/Fixtures' => 'Symfony\Bridge\Doctrine\Tests\Fixtures'], true)); $config->setSchemaManagerFactory(new DefaultSchemaManagerFactory()); - - if (method_exists($config, 'setLazyGhostObjectEnabled')) { - $config->setLazyGhostObjectEnabled(true); - } + $config->setLazyGhostObjectEnabled(true); return $config; } diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/BaseUser.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/BaseUser.php index c8be89cc760e0..95593ab20fdee 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/BaseUser.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/BaseUser.php @@ -42,11 +42,6 @@ public function getId(): int return $this->id; } - public function getUsername(): string - { - return $this->username; - } - public function getUserIdentifier(): string { return $this->username; diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/User.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/User.php index 44f01849d91b6..6f1255c1408df 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/User.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/User.php @@ -44,11 +44,6 @@ public function getPassword(): ?string { } - public function getUsername(): string - { - return $this->name; - } - public function getUserIdentifier(): string { return $this->name; diff --git a/src/Symfony/Bridge/Doctrine/Tests/Middleware/Debug/MiddlewareTest.php b/src/Symfony/Bridge/Doctrine/Tests/Middleware/Debug/MiddlewareTest.php index 7fa670cbc15bb..4f1e9e205d0bc 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Middleware/Debug/MiddlewareTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Middleware/Debug/MiddlewareTest.php @@ -52,9 +52,7 @@ private function init(bool $withStopwatch = true): void $config = ORMSetup::createConfiguration(true); $config->setSchemaManagerFactory(new DefaultSchemaManagerFactory()); - if (method_exists($config, 'setLazyGhostObjectEnabled')) { - $config->setLazyGhostObjectEnabled(true); - } + $config->setLazyGhostObjectEnabled(true); $this->debugDataHolder = new DebugDataHolder(); $config->setMiddlewares([new Middleware($this->debugDataHolder, $this->stopwatch)]); diff --git a/src/Symfony/Bridge/Doctrine/Tests/PropertyInfo/DoctrineExtractorTest.php b/src/Symfony/Bridge/Doctrine/Tests/PropertyInfo/DoctrineExtractorTest.php index 93cced43d15f7..36c1b98e3f7cd 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/PropertyInfo/DoctrineExtractorTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/PropertyInfo/DoctrineExtractorTest.php @@ -39,9 +39,7 @@ private function createExtractor(): DoctrineExtractor $config = ORMSetup::createConfiguration(true); $config->setMetadataDriverImpl(new AttributeDriver([__DIR__.'/../Tests/Fixtures' => 'Symfony\Bridge\Doctrine\Tests\Fixtures'], true)); $config->setSchemaManagerFactory(new DefaultSchemaManagerFactory()); - if (method_exists($config, 'setLazyGhostObjectEnabled')) { - $config->setLazyGhostObjectEnabled(true); - } + $config->setLazyGhostObjectEnabled(true); $eventManager = new EventManager(); $entityManager = new EntityManager(DriverManager::getConnection(['driver' => 'pdo_sqlite'], $config, $eventManager), $config, $eventManager); diff --git a/src/Symfony/Bridge/Doctrine/Tests/Security/RememberMe/DoctrineTokenProviderTest.php b/src/Symfony/Bridge/Doctrine/Tests/Security/RememberMe/DoctrineTokenProviderTest.php index 23accc3d088ce..545d5926133dc 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Security/RememberMe/DoctrineTokenProviderTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Security/RememberMe/DoctrineTokenProviderTest.php @@ -122,9 +122,7 @@ private function bootstrapProvider(): DoctrineTokenProvider $config = ORMSetup::createConfiguration(true); $config->setSchemaManagerFactory(new DefaultSchemaManagerFactory()); - if (method_exists($config, 'setLazyGhostObjectEnabled')) { - $config->setLazyGhostObjectEnabled(true); - } + $config->setLazyGhostObjectEnabled(true); $connection = DriverManager::getConnection([ 'driver' => 'pdo_sqlite', diff --git a/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php b/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php index 7d068d4c56307..d0becddbc76f3 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php @@ -49,7 +49,7 @@ public function testRefreshUserGetsUserByPrimaryKey() $this->assertSame($user1, $provider->refreshUser($user1)); } - public function testLoadUserByUsername() + public function testLoadUserByIdentifier() { $em = DoctrineTestHelper::createTestEntityManager(); $this->createSchema($em); @@ -64,7 +64,7 @@ public function testLoadUserByUsername() $this->assertSame($user, $provider->loadUserByIdentifier('user1')); } - public function testLoadUserByUsernameWithUserLoaderRepositoryAndWithoutProperty() + public function testLoadUserByIdentifierWithUserLoaderRepositoryAndWithoutProperty() { $user = new User(1, 1, 'user1'); @@ -86,7 +86,7 @@ public function testLoadUserByUsernameWithUserLoaderRepositoryAndWithoutProperty $this->assertSame($user, $provider->loadUserByIdentifier('user1')); } - public function testLoadUserByUsernameWithNonUserLoaderRepositoryAndWithoutProperty() + public function testLoadUserByIdentifierWithNonUserLoaderRepositoryAndWithoutProperty() { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('You must either make the "Symfony\Bridge\Doctrine\Tests\Fixtures\User" entity Doctrine Repository ("Doctrine\ORM\EntityRepository") implement "Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface" or set the "property" option in the corresponding entity provider configuration.'); @@ -150,7 +150,7 @@ public function testSupportProxy() $this->assertTrue($provider->supportsClass($user2::class)); } - public function testLoadUserByUserNameShouldLoadUserWhenProperInterfaceProvided() + public function testLoadUserByIdentifierShouldLoadUserWhenProperInterfaceProvided() { $repository = $this->createMock(UserLoaderRepository::class); $repository->expects($this->once()) @@ -168,7 +168,7 @@ public function testLoadUserByUserNameShouldLoadUserWhenProperInterfaceProvided( $provider->loadUserByIdentifier('name'); } - public function testLoadUserByUserNameShouldDeclineInvalidInterface() + public function testLoadUserByIdentifierShouldDeclineInvalidInterface() { $this->expectException(\InvalidArgumentException::class); $repository = $this->createMock(ObjectRepository::class); diff --git a/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityTest.php b/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityTest.php index 9e334e8ff1dbb..4380bba494bba 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class UniqueEntityTest extends TestCase { public function testAttributeWithDefaultProperty() { $metadata = new ClassMetadata(UniqueEntityDummyOne::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); /** @var UniqueEntity $constraint */ @@ -35,7 +35,7 @@ public function testAttributeWithDefaultProperty() public function testAttributeWithCustomizedService() { $metadata = new ClassMetadata(UniqueEntityDummyTwo::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); /** @var UniqueEntity $constraint */ @@ -50,7 +50,7 @@ public function testAttributeWithCustomizedService() public function testAttributeWithGroupsAndPaylod() { $metadata = new ClassMetadata(UniqueEntityDummyThree::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); /** @var UniqueEntity $constraint */ diff --git a/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php b/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php index 70a30f3173920..383f11c26f9a9 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php @@ -39,7 +39,7 @@ class DoctrineLoaderTest extends TestCase public function testLoadClassMetadata() { $validator = Validation::createValidatorBuilder() - ->enableAnnotationMapping(true) + ->enableAttributeMapping() ->addLoader(new DoctrineLoader(DoctrineTestHelper::createTestEntityManager(), '{^Symfony\\\\Bridge\\\\Doctrine\\\\Tests\\\\Fixtures\\\\DoctrineLoader}')) ->getValidator() ; @@ -142,7 +142,7 @@ public function testExtractEnum() { $validator = Validation::createValidatorBuilder() ->addMethodMapping('loadValidatorMetadata') - ->enableAnnotationMapping(true) + ->enableAttributeMapping() ->addLoader(new DoctrineLoader(DoctrineTestHelper::createTestEntityManager(), '{^Symfony\\\\Bridge\\\\Doctrine\\\\Tests\\\\Fixtures\\\\DoctrineLoader}')) ->getValidator() ; @@ -159,7 +159,7 @@ public function testExtractEnum() public function testFieldMappingsConfiguration() { $validator = Validation::createValidatorBuilder() - ->enableAnnotationMapping(true) + ->enableAttributeMapping() ->addXmlMappings([__DIR__.'/../Resources/validator/BaseUser.xml']) ->addLoader( new DoctrineLoader( @@ -200,7 +200,7 @@ public static function regexpProvider(): array public function testClassNoAutoMapping() { $validator = Validation::createValidatorBuilder() - ->enableAnnotationMapping(true) + ->enableAttributeMapping() ->addLoader(new DoctrineLoader(DoctrineTestHelper::createTestEntityManager(), '{.*}')) ->getValidator(); diff --git a/src/Symfony/Bridge/PsrHttpMessage/Tests/Functional/CovertTest.php b/src/Symfony/Bridge/PsrHttpMessage/Tests/Functional/CovertTest.php index d26efb6de21b6..b0ea766922db5 100644 --- a/src/Symfony/Bridge/PsrHttpMessage/Tests/Functional/CovertTest.php +++ b/src/Symfony/Bridge/PsrHttpMessage/Tests/Functional/CovertTest.php @@ -195,11 +195,7 @@ public static function responseProvider(): array ['x-symfony' => ['3.4']] ); - if (method_exists(Cookie::class, 'create')) { - $cookie = Cookie::create('city', 'Lille', new \DateTime('Wed, 13 Jan 2021 22:23:01 GMT')); - } else { - $cookie = new Cookie('city', 'Lille', new \DateTime('Wed, 13 Jan 2021 22:23:01 GMT')); - } + $cookie = Cookie::create('city', 'Lille', new \DateTime('Wed, 13 Jan 2021 22:23:01 GMT')); $sfResponse->headers->setCookie($cookie); $body = Psr7Stream::create(); diff --git a/src/Symfony/Bridge/Twig/AppVariable.php b/src/Symfony/Bridge/Twig/AppVariable.php index 50b53d2c88886..9d5891240528a 100644 --- a/src/Symfony/Bridge/Twig/AppVariable.php +++ b/src/Symfony/Bridge/Twig/AppVariable.php @@ -13,7 +13,8 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; +use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -100,7 +101,7 @@ public function getRequest(): ?Request /** * Returns the current session. */ - public function getSession(): ?Session + public function getSession(): ?SessionInterface { if (!isset($this->requestStack)) { throw new \RuntimeException('The "app.session" variable is not available.'); @@ -152,13 +153,15 @@ public function getLocale(): string public function getFlashes(string|array $types = null): array { try { - if (null === $session = $this->getSession()) { - return []; - } + $session = $this->getSession(); } catch (\RuntimeException) { return []; } + if (!$session instanceof FlashBagAwareSessionInterface) { + return []; + } + if (null === $types || '' === $types || [] === $types) { return $session->getFlashBag()->all(); } diff --git a/src/Symfony/Bridge/Twig/CHANGELOG.md b/src/Symfony/Bridge/Twig/CHANGELOG.md index 9613d9a3fd6e0..d8b45c13a56f7 100644 --- a/src/Symfony/Bridge/Twig/CHANGELOG.md +++ b/src/Symfony/Bridge/Twig/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.0 +--- + + * Drop support for Twig 2 + 6.3 --- diff --git a/src/Symfony/Bridge/Twig/Extension/CodeExtension.php b/src/Symfony/Bridge/Twig/Extension/CodeExtension.php index 748d60cb154b1..d6bb18e43b0c0 100644 --- a/src/Symfony/Bridge/Twig/Extension/CodeExtension.php +++ b/src/Symfony/Bridge/Twig/Extension/CodeExtension.php @@ -117,11 +117,21 @@ public function fileExcerpt(string $file, int $line, int $srcContext = 3): ?stri // highlight_file could throw warnings // see https://bugs.php.net/25725 $code = @highlight_file($file, true); - // remove main code/span tags - $code = preg_replace('#^\s*(.*)\s*#s', '\\1', $code); - // split multiline spans - $code = preg_replace_callback('#]++)>((?:[^<]*+
)++[^<]*+)
#', fn ($m) => "".str_replace('
', "

", $m[2]).'', $code); - $content = explode('
', $code); + if (\PHP_VERSION_ID >= 80300) { + // remove main pre/code tags + $code = preg_replace('#^\s*(.*)\s*#s', '\\1', $code); + // split multiline code tags + $code = preg_replace_callback('#]++)>((?:[^<]*+\\n)++[^<]*+)#', fn ($m) => "".str_replace("\n", "\n", $m[2]).'', $code); + // Convert spaces to html entities to preserve indentation when rendered + $code = str_replace(' ', ' ', $code); + $content = explode("\n", $code); + } else { + // remove main code/span tags + $code = preg_replace('#^\s*(.*)\s*#s', '\\1', $code); + // split multiline spans + $code = preg_replace_callback('#]++)>((?:[^<]*+
)++[^<]*+)
#', fn ($m) => "".str_replace('
', "

", $m[2]).'', $code); + $content = explode('
', $code); + } $lines = []; if (0 > $srcContext) { diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/WorkflowExtensionTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/WorkflowExtensionTest.php index 8a16e5b2c3f2e..21f9e663b27b4 100644 --- a/src/Symfony/Bridge/Twig/Tests/Extension/WorkflowExtensionTest.php +++ b/src/Symfony/Bridge/Twig/Tests/Extension/WorkflowExtensionTest.php @@ -17,7 +17,6 @@ use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore; use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore; use Symfony\Component\Workflow\Registry; -use Symfony\Component\Workflow\SupportStrategy\ClassInstanceSupportStrategy; use Symfony\Component\Workflow\SupportStrategy\InstanceOfSupportStrategy; use Symfony\Component\Workflow\Transition; use Symfony\Component\Workflow\TransitionBlockerList; @@ -36,25 +35,19 @@ protected function setUp(): void new Transition('t2', 'waiting_for_payment', 'processed'), ]; - $metadataStore = null; - if (class_exists(InMemoryMetadataStore::class)) { - $transitionsMetadata = new \SplObjectStorage(); - $transitionsMetadata->attach($this->t1, ['title' => 't1 title']); - $metadataStore = new InMemoryMetadataStore( - ['title' => 'workflow title'], - ['orderer' => ['title' => 'ordered title']], - $transitionsMetadata - ); - } + $transitionsMetadata = new \SplObjectStorage(); + $transitionsMetadata->attach($this->t1, ['title' => 't1 title']); + $metadataStore = new InMemoryMetadataStore( + ['title' => 'workflow title'], + ['orderer' => ['title' => 'ordered title']], + $transitionsMetadata + ); $definition = new Definition($places, $transitions, null, $metadataStore); $workflow = new Workflow($definition, new MethodMarkingStore()); $registry = new Registry(); - $addWorkflow = method_exists($registry, 'addWorkflow') ? 'addWorkflow' : 'add'; - $supportStrategy = class_exists(InstanceOfSupportStrategy::class) - ? new InstanceOfSupportStrategy(Subject::class) - : new ClassInstanceSupportStrategy(Subject::class); - $registry->$addWorkflow($workflow, $supportStrategy); + $supportStrategy = new InstanceOfSupportStrategy(Subject::class); + $registry->addWorkflow($workflow, $supportStrategy); $this->extension = new WorkflowExtension($registry); } diff --git a/src/Symfony/Bridge/Twig/composer.json b/src/Symfony/Bridge/Twig/composer.json index 3522e9ff6bf88..ebbbea2a46138 100644 --- a/src/Symfony/Bridge/Twig/composer.json +++ b/src/Symfony/Bridge/Twig/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=8.2", "symfony/translation-contracts": "^2.5|^3", - "twig/twig": "^2.13|^3.0.4" + "twig/twig": "^3.0.4" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3|^4", diff --git a/src/Symfony/Bundle/DebugBundle/DependencyInjection/DebugExtension.php b/src/Symfony/Bundle/DebugBundle/DependencyInjection/DebugExtension.php index 8e06383456654..f03ed754c9d79 100644 --- a/src/Symfony/Bundle/DebugBundle/DependencyInjection/DebugExtension.php +++ b/src/Symfony/Bundle/DebugBundle/DependencyInjection/DebugExtension.php @@ -20,7 +20,6 @@ use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\VarDumper\Caster\ReflectionCaster; -use Symfony\Component\VarDumper\Dumper\CliDumper; use Symfony\Component\VarDumper\Dumper\HtmlDumper; /** @@ -39,14 +38,10 @@ public function load(array $configs, ContainerBuilder $container): void $container->getDefinition('var_dumper.cloner') ->addMethodCall('setMaxItems', [$config['max_items']]) ->addMethodCall('setMinDepth', [$config['min_depth']]) - ->addMethodCall('setMaxString', [$config['max_string_length']]); + ->addMethodCall('setMaxString', [$config['max_string_length']]) + ->addMethodCall('addCasters', [ReflectionCaster::UNSET_CLOSURE_FILE_INFO]); - if (method_exists(ReflectionCaster::class, 'unsetClosureFileInfo')) { - $container->getDefinition('var_dumper.cloner') - ->addMethodCall('addCasters', [ReflectionCaster::UNSET_CLOSURE_FILE_INFO]); - } - - if (method_exists(HtmlDumper::class, 'setTheme') && 'dark' !== $config['theme']) { + if ('dark' !== $config['theme']) { $container->getDefinition('var_dumper.html_dumper') ->addMethodCall('setTheme', [$config['theme']]); } @@ -80,13 +75,11 @@ public function load(array $configs, ContainerBuilder $container): void ; } - if (method_exists(CliDumper::class, 'setDisplayOptions')) { - $container->getDefinition('var_dumper.cli_dumper') - ->addMethodCall('setDisplayOptions', [[ - 'fileLinkFormat' => new Reference('debug.file_link_formatter', ContainerBuilder::IGNORE_ON_INVALID_REFERENCE), - ]]) - ; - } + $container->getDefinition('var_dumper.cli_dumper') + ->addMethodCall('setDisplayOptions', [[ + 'fileLinkFormat' => new Reference('debug.file_link_formatter', ContainerBuilder::IGNORE_ON_INVALID_REFERENCE), + ]]) + ; if (!class_exists(Command::class) || !class_exists(ServerLogCommand::class)) { $container->removeDefinition('monolog.command.server_log'); diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 2614cc1d23531..9c1b1842aeaa3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 7.0 --- + * Add support for the experimental revamped version of the Serializer component * Remove command `translation:update`, use `translation:extract` instead * Make the `http_method_override` config option default to `false` * Remove `AbstractController::renderForm()`, use `render()` instead @@ -14,6 +15,16 @@ CHANGELOG * Remove the integration of Doctrine annotations, use native attributes instead * Remove `EnableLoggerDebugModePass`, use argument `$debug` of HttpKernel's `Logger` instead * Remove `AddDebugLogProcessorPass::configureLogger()`, use HttpKernel's `DebugLoggerConfigurator` instead + * Make the `framework.handle_all_throwables` config option default to `true` + * Make the `framework.php_errors.log` config option default to `true` + * Make the `framework.session.cookie_secure` config option default to `auto` + * Make the `framework.session.cookie_samesite` config option default to `lax` + * Make the `framework.session.handler_id` default to null if `save_path` is not set and to `session.handler.native_file` otherwise + * Make the `framework.uid.default_uuid_version` config option default to `7` + * Make the `framework.uid.time_based_uuid_version` config option default to `7` + * Make the `framework.validation.email_validation_mode` config option default to `html5` + * Remove the `framework.validation.enable_annotations` config option, use `framework.validation.enable_attributes` instead + * Remove the `framework.serializer.enable_annotations` config option, use `framework.serializer.enable_attributes` instead 6.4 --- @@ -31,11 +42,14 @@ CHANGELOG * Deprecate not setting the `framework.php_errors.log` config option; it will default to `true` in 7.0 * Deprecate not setting the `framework.session.cookie_secure` config option; it will default to `auto` in 7.0 * Deprecate not setting the `framework.session.cookie_samesite` config option; it will default to `lax` in 7.0 - * Deprecate not setting the `framework.session.handler_id` config option; it will default to `session.handler.native_file` when `framework.session.save_path` is set or `null` otherwise in 7.0 - * Deprecate not setting the `framework.session.save_path` config option when `framework.session.handler_id` is not set; it will default to `null` in 7.0 + * Deprecate not setting either `framework.session.handler_id` or `save_path` config options; `handler_id` will + default to null in 7.0 if `save_path` is not set and to `session.handler.native_file` otherwise * Deprecate not setting the `framework.uid.default_uuid_version` config option; it will default to `7` in 7.0 * Deprecate not setting the `framework.uid.time_based_uuid_version` config option; it will default to `7` in 7.0 * Deprecate not setting the `framework.validation.email_validation_mode` config option; it will default to `html5` in 7.0 + * Deprecate `framework.validation.enable_annotations`, use `framework.validation.enable_attributes` instead + * Deprecate `framework.serializer.enable_annotations`, use `framework.serializer.enable_attributes` instead + * Add `array $tokenAttributes = []` optional parameter to `KernelBrowser::loginUser()` 6.3 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/SerializerLazyGhostCacheWarmer.php b/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/SerializerLazyGhostCacheWarmer.php new file mode 100644 index 0000000000000..19c6fd236707a --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/SerializerLazyGhostCacheWarmer.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\CacheWarmer; + +use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmer; +use Symfony\Component\Serializer\Type\Type; +use Symfony\Component\VarExporter\ProxyHelper; + +/** + * Generates lazy ghost {@see Symfony\Component\VarExporter\LazyGhostTrait} + * PHP files for $serializable types. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class SerializerLazyGhostCacheWarmer extends CacheWarmer +{ + /** + * @param list $serializable + */ + public function __construct( + private readonly array $serializable, + private readonly string $lazyGhostCacheDir, + ) { + } + + public function warmUp(string $cacheDir): array + { + if (!file_exists($this->lazyGhostCacheDir)) { + mkdir($this->lazyGhostCacheDir, recursive: true); + } + + foreach ($this->serializable as $s) { + $type = Type::fromString($s); + + if (!$type->isObject() || !$type->hasClass()) { + continue; + } + + $this->warmClassLazyGhost($type->className()); + } + + return []; + } + + public function isOptional(): bool + { + return false; + } + + /** + * @param class-string $className + */ + private function warmClassLazyGhost(string $className): void + { + $path = sprintf('%s%s%s.php', $this->lazyGhostCacheDir, \DIRECTORY_SEPARATOR, hash('xxh128', $className)); + + $this->writeCacheFile($path, sprintf( + 'class %s%s', + sprintf('%sGhost', preg_replace('/\\\\/', '', $className)), + ProxyHelper::generateLazyGhost(new \ReflectionClass($className)), + )); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/SerializerTemplateCacheWarmer.php b/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/SerializerTemplateCacheWarmer.php new file mode 100644 index 0000000000000..4801da6682821 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/SerializerTemplateCacheWarmer.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\CacheWarmer; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmer; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Deserialize\Template\Template as DeserializeTemplate; +use Symfony\Component\Serializer\Exception\ExceptionInterface; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Serialize\Template\Template as SerializeTemplate; +use Symfony\Component\Serializer\Template\TemplateVariant; +use Symfony\Component\Serializer\Template\TemplateVariation; +use Symfony\Component\Serializer\Template\TemplateVariationExtractorInterface; +use Symfony\Component\Serializer\Type\Type; + +/** + * Generates serialization and deserialization templates PHP files. + * + * It generates templates for each $formats and each variants + * of $serializable types limited to $maxVariants. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class SerializerTemplateCacheWarmer extends CacheWarmer +{ + /** + * @param list $serializable + * @param list $formats + */ + public function __construct( + private readonly array $serializable, + private readonly SerializeTemplate $serializeTemplate, + private readonly DeserializeTemplate $deserializeTemplate, + private readonly TemplateVariationExtractorInterface $templateVariationExtractor, + private readonly string $templateCacheDir, + private readonly array $formats, + private readonly int $maxVariants, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function warmUp(string $cacheDir): array + { + if (!file_exists($this->templateCacheDir)) { + mkdir($this->templateCacheDir, recursive: true); + } + + foreach ($this->serializable as $s) { + $type = Type::fromString($s); + + $variations = $this->templateVariationExtractor->extractVariationsFromType($type); + $variants = $this->variants($variations); + + if (\count($variants) > $this->maxVariants) { + $this->logger->debug('Too many variants for "{type}", keeping only the first {maxVariants}.', ['type' => $s, 'maxVariants' => $this->maxVariants]); + $variants = \array_slice($variants, offset: 0, length: $this->maxVariants); + } + + foreach ($this->formats as $format) { + $this->warmTemplates($type, $variants, $format); + } + } + + return []; + } + + public function isOptional(): bool + { + return false; + } + + /** + * @param list $variants + */ + private function warmTemplates(Type $type, array $variants, string $format): void + { + foreach ($variants as $variant) { + try { + $this->writeCacheFile( + $this->serializeTemplate->path($type, $format, $variant['serialize']->config), + $this->serializeTemplate->content($type, $format, $variant['serialize']->config), + ); + } catch (ExceptionInterface $e) { + $this->logger->debug('Cannot generate serialize "{format}" template for "{type}": {exception}', [ + 'format' => $format, + 'type' => (string) $type, + 'exception' => $e, + ]); + } + + try { + $this->writeCacheFile( + $this->deserializeTemplate->path($type, $format, $variant['deserialize']->config), + $this->deserializeTemplate->content($type, $format, $variant['deserialize']->config), + ); + } catch (ExceptionInterface $e) { + $this->logger->debug('Cannot generate deserialize "{format}" template for "{type}": {exception}', [ + 'format' => $format, + 'type' => (string) $type, + 'exception' => $e, + ]); + } + } + } + + /** + * @param list $variations + * + * @return list + */ + private function variants(array $variations): array + { + $variants = [[]]; + + foreach ($variations as $variation) { + foreach ($variants as $variant) { + $variants[] = array_merge([$variation], $variant); + } + } + + return array_map(fn (array $variations): array => [ + 'serialize' => new TemplateVariant(new SerializeConfig(), $variations), + 'deserialize' => new TemplateVariant(new DeserializeConfig(), $variations), + ], $variants); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/DebugAutowiringCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/DebugAutowiringCommand.php index 40aa0e8ca96ea..6ba01bf4d67f3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/DebugAutowiringCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/DebugAutowiringCommand.php @@ -15,7 +15,6 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; -use Symfony\Component\Console\Formatter\OutputFormatterStyle; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -34,12 +33,10 @@ #[AsCommand(name: 'debug:autowiring', description: 'List classes/interfaces you can use for autowiring')] class DebugAutowiringCommand extends ContainerDebugCommand { - private bool $supportsHref; private ?FileLinkFormatter $fileLinkFormatter; public function __construct(string $name = null, FileLinkFormatter $fileLinkFormatter = null) { - $this->supportsHref = method_exists(OutputFormatterStyle::class, 'setHref'); $this->fileLinkFormatter = $fileLinkFormatter; parent::__construct($name); } @@ -124,7 +121,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $serviceLine = sprintf('%s', $serviceId); - if ($this->supportsHref && '' !== $fileLink = $this->getFileLink($previousId)) { + if ('' !== $fileLink = $this->getFileLink($previousId)) { $serviceLine = substr($serviceId, \strlen($previousId)); $serviceLine = sprintf('%s', $fileLink, $previousId).('' !== $serviceLine ? sprintf('%s', $serviceLine) : ''); } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/AddAnnotationsCachedReaderPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/AddAnnotationsCachedReaderPass.php deleted file mode 100644 index 2105a54df9f36..0000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/AddAnnotationsCachedReaderPass.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler; - -use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; -use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; - -/** - * @internal - */ -class AddAnnotationsCachedReaderPass implements CompilerPassInterface -{ - public function process(ContainerBuilder $container): void - { - // "annotations.cached_reader" is wired late so that any passes using - // "annotation_reader" at build time don't get any cache - foreach ($container->findTaggedServiceIds('annotations.cached_reader') as $id => $tags) { - $reader = $container->getDefinition($id); - $properties = $reader->getProperties(); - - if (isset($properties['cacheProviderBackup'])) { - $provider = $properties['cacheProviderBackup']->getValues()[0]; - unset($properties['cacheProviderBackup']); - $reader->setProperties($properties); - $reader->replaceArgument(1, $provider); - } elseif (4 <= \count($arguments = $reader->getArguments()) && $arguments[3] instanceof ServiceClosureArgument) { - $arguments[1] = $arguments[3]->getValues()[0]; - unset($arguments[3]); - $reader->setArguments($arguments); - } - } - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index c21b7369d894b..532c406cd048d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -22,7 +22,6 @@ class UnusedTagsPass implements CompilerPassInterface { private const KNOWN_TAGS = [ - 'annotations.cached_reader', 'asset_mapper.compiler', 'asset_mapper.importmap.resolver', 'assets.package', @@ -87,8 +86,11 @@ class UnusedTagsPass implements CompilerPassInterface 'security.expression_language_provider', 'security.remember_me_handler', 'security.voter', + 'serializer.deserialize.template_generator.eager', + 'serializer.deserialize.template_generator.lazy', 'serializer.encoder', 'serializer.normalizer', + 'serializer.serialize.template_generator', 'texter.transport_factory', 'translation.dumper', 'translation.extractor', diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 7e94b1a576ddf..f9010e91e82d2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -82,15 +82,6 @@ public function getConfigTreeBuilder(): TreeBuilder return $v; }) ->end() - ->validate() - ->always(function ($v) { - if (!isset($v['handle_all_throwables'])) { - trigger_deprecation('symfony/framework-bundle', '6.4', 'Not setting the "framework.handle_all_throwables" config option is deprecated. It will default to "true" in 7.0.'); - } - - return $v; - }) - ->end() ->fixXmlConfig('enabled_locale') ->children() ->scalarNode('secret')->end() @@ -137,7 +128,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->scalarNode('error_controller') ->defaultValue('error_controller') ->end() - ->booleanNode('handle_all_throwables')->info('HttpKernel will handle all kinds of \Throwable')->end() + ->booleanNode('handle_all_throwables')->info('HttpKernel will handle all kinds of \Throwable')->defaultTrue()->end() ->end() ; @@ -649,42 +640,15 @@ private function addRouterSection(ArrayNodeDefinition $rootNode): void private function addSessionSection(ArrayNodeDefinition $rootNode): void { $rootNode - ->validate() - ->always(function (array $v): array { - if ($v['session']['enabled']) { - if (!\array_key_exists('cookie_secure', $v['session'])) { - trigger_deprecation('symfony/framework-bundle', '6.4', 'Not setting the "framework.session.cookie_secure" config option is deprecated. It will default to "auto" in 7.0.'); - } - - if (!\array_key_exists('cookie_samesite', $v['session'])) { - trigger_deprecation('symfony/framework-bundle', '6.4', 'Not setting the "framework.session.cookie_samesite" config option is deprecated. It will default to "lax" in 7.0.'); - } - - if (!\array_key_exists('handler_id', $v['session'])) { - if (!\array_key_exists('save_path', $v['session'])) { - trigger_deprecation('symfony/framework-bundle', '6.4', 'Not setting the "framework.session.save_path" config option when the "framework.session.handler_id" config option is not set either is deprecated. Both options will default to "null" in 7.0.'); - } else { - trigger_deprecation('symfony/framework-bundle', '6.4', 'Not setting the "framework.session.handler_id" config option is deprecated. It will default to "session.handler.native_file" when "framework.session.save_path" is set or "null" otherwise in 7.0.'); - } - } - } - - $v['session'] += [ - 'cookie_samesite' => null, - 'handler_id' => 'session.handler.native_file', - 'save_path' => '%kernel.cache_dir%/sessions', - ]; - - return $v; - }) - ->end() ->children() ->arrayNode('session') ->info('session configuration') ->canBeEnabled() ->children() ->scalarNode('storage_factory_id')->defaultValue('session.storage.factory.native')->end() - ->scalarNode('handler_id')->end() + ->scalarNode('handler_id') + ->info('Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null.') + ->end() ->scalarNode('name') ->validate() ->ifTrue(function ($v) { @@ -698,14 +662,16 @@ private function addSessionSection(ArrayNodeDefinition $rootNode): void ->scalarNode('cookie_lifetime')->end() ->scalarNode('cookie_path')->end() ->scalarNode('cookie_domain')->end() - ->enumNode('cookie_secure')->values([true, false, 'auto'])->end() + ->enumNode('cookie_secure')->values([true, false, 'auto'])->defaultValue('auto')->end() ->booleanNode('cookie_httponly')->defaultTrue()->end() - ->enumNode('cookie_samesite')->values([null, Cookie::SAMESITE_LAX, Cookie::SAMESITE_STRICT, Cookie::SAMESITE_NONE])->end() + ->enumNode('cookie_samesite')->values([null, Cookie::SAMESITE_LAX, Cookie::SAMESITE_STRICT, Cookie::SAMESITE_NONE])->defaultValue('lax')->end() ->booleanNode('use_cookies')->end() ->scalarNode('gc_divisor')->end() ->scalarNode('gc_probability')->defaultValue(1)->end() ->scalarNode('gc_maxlifetime')->end() - ->scalarNode('save_path')->end() + ->scalarNode('save_path') + ->info('Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null') + ->end() ->integerNode('metadata_update_threshold') ->defaultValue(0) ->info('seconds to wait between 2 session metadata updates') @@ -1017,22 +983,13 @@ private function addTranslatorSection(ArrayNodeDefinition $rootNode, callable $e private function addValidationSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone): void { $rootNode - ->validate() - ->always(function ($v) { - if ($v['validation']['enabled'] && !\array_key_exists('email_validation_mode', $v['validation'])) { - trigger_deprecation('symfony/framework-bundle', '6.4', 'Not setting the "framework.validation.email_validation_mode" config option is deprecated. It will default to "html5" in 7.0.'); - } - - return $v; - }) - ->end() ->children() ->arrayNode('validation') ->info('validation configuration') ->{$enableIfStandalone('symfony/validator', Validation::class)}() ->children() ->scalarNode('cache')->end() - ->booleanNode('enable_annotations')->{!class_exists(FullStack::class) ? 'defaultTrue' : 'defaultFalse'}()->end() + ->booleanNode('enable_attributes')->{!class_exists(FullStack::class) ? 'defaultTrue' : 'defaultFalse'}()->end() ->arrayNode('static_method') ->defaultValue(['loadValidatorMetadata']) ->prototype('scalar')->end() @@ -1040,7 +997,7 @@ private function addValidationSection(ArrayNodeDefinition $rootNode, callable $e ->validate()->castToArray()->end() ->end() ->scalarNode('translation_domain')->defaultValue('validators')->end() - ->enumNode('email_validation_mode')->values(['html5', 'loose', 'strict'])->end() + ->enumNode('email_validation_mode')->values(['html5', 'loose', 'strict'])->defaultValue('html5')->end() ->arrayNode('mapping') ->addDefaultsIfNotSet() ->fixXmlConfig('path') @@ -1134,8 +1091,10 @@ private function addSerializerSection(ArrayNodeDefinition $rootNode, callable $e ->arrayNode('serializer') ->info('serializer configuration') ->{$enableIfStandalone('symfony/serializer', Serializer::class)}() + ->fixXmlConfig('serializable_path') + ->fixXmlConfig('format') ->children() - ->booleanNode('enable_annotations')->{!class_exists(FullStack::class) ? 'defaultTrue' : 'defaultFalse'}()->end() + ->booleanNode('enable_attributes')->{!class_exists(FullStack::class) ? 'defaultTrue' : 'defaultFalse'}()->end() ->scalarNode('name_converter')->end() ->scalarNode('circular_reference_handler')->end() ->scalarNode('max_depth_handler')->end() @@ -1158,6 +1117,31 @@ private function addSerializerSection(ArrayNodeDefinition $rootNode, callable $e ->defaultValue([]) ->prototype('variable')->end() ->end() + ->arrayNode('serializable_paths') + ->info('Defines where to find classes to serialized/deserialized.') + ->beforeNormalization()->ifString()->then(function ($v) { return [$v]; })->end() + ->prototype('scalar')->end() + ->defaultValue([]) + ->end() + ->arrayNode('formats') + ->info('Defines formats to generate template during cache warm up.') + ->beforeNormalization()->ifString()->then(function ($v) { return [$v]; })->end() + ->prototype('scalar')->end() + ->defaultValue(['json']) + ->end() + ->integerNode('max_variants') + ->info('Defines the maximum template variants allowed for each class during cache warm up.') + ->defaultValue(32) + ->min(0) + ->end() + ->booleanNode('lazy_deserialization') + ->info('Defines whether to read data lazily or eagerly.') + ->defaultFalse() + ->end() + ->booleanNode('lazy_instantiation') + ->info('Defines whether to instantiate objects lazily or eagerly.') + ->defaultFalse() + ->end() ->end() ->end() ->end() @@ -1290,17 +1274,6 @@ private function addCacheSection(ArrayNodeDefinition $rootNode, callable $willBe private function addPhpErrorsSection(ArrayNodeDefinition $rootNode): void { $rootNode - ->validate() - ->always(function (array $v): array { - if (!\array_key_exists('log', $v['php_errors'])) { - trigger_deprecation('symfony/framework-bundle', '6.4', 'Not setting the "framework.php_errors.log" config option is deprecated. It will default to "true" in 7.0.'); - - $v['php_errors']['log'] = $this->debug; - } - - return $v; - }) - ->end() ->children() ->arrayNode('php_errors') ->info('PHP errors handling configuration') @@ -1310,6 +1283,7 @@ private function addPhpErrorsSection(ArrayNodeDefinition $rootNode): void ->info('Use the application logger instead of the PHP logger for logging PHP errors.') ->example('"true" to use the default configuration: log all errors. "false" to disable. An integer bit field of E_* constants, or an array mapping E_* constants to log levels.') ->treatNullLike($this->debug) + ->defaultTrue() ->beforeNormalization() ->ifArray() ->then(function (array $v): array { @@ -2322,23 +2296,6 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode, callable $ private function addUidSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone): void { $rootNode - ->validate() - ->always(function ($v) { - if ($v['uid']['enabled']) { - if (!\array_key_exists('default_uuid_version', $v['uid'])) { - trigger_deprecation('symfony/framework-bundle', '6.4', 'Not setting the "framework.uid.default_uuid_version" config option is deprecated. It will default to "7" in 7.0.'); - } - - if (!\array_key_exists('time_based_uuid_version', $v['uid'])) { - trigger_deprecation('symfony/framework-bundle', '6.4', 'Not setting the "framework.uid.time_based_uuid_version" config option is deprecated. It will default to "7" in 7.0.'); - } - } - - $v['uid'] += ['default_uuid_version' => 6, 'time_based_uuid_version' => 6]; - - return $v; - }) - ->end() ->children() ->arrayNode('uid') ->info('Uid configuration') @@ -2347,6 +2304,7 @@ private function addUidSection(ArrayNodeDefinition $rootNode, callable $enableIf ->children() ->enumNode('default_uuid_version') ->values([7, 6, 4, 1]) + ->defaultValue(7) ->end() ->enumNode('name_based_uuid_version') ->defaultValue(5) @@ -2357,6 +2315,7 @@ private function addUidSection(ArrayNodeDefinition $rootNode, callable $enableIf ->end() ->enumNode('time_based_uuid_version') ->values([7, 6, 1]) + ->defaultValue(7) ->end() ->scalarNode('time_based_uuid_node') ->cannotBeEmpty() diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 91e5f516c0bc1..7872f2298be4b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -14,6 +14,10 @@ use Composer\InstalledVersions; use Http\Client\HttpAsyncClient; use Http\Client\HttpClient; +use Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface; +use Symfony\Component\Serializer\Serialize\SerializerInterface as ExperimentalSerializerInterface; +use Symfony\Component\Serializer\Type\PhpstanTypeExtractor; +use Symfony\Component\Serializer\Type\TypeExtractorInterface; use phpDocumentor\Reflection\DocBlockFactoryInterface; use phpDocumentor\Reflection\Types\ContextFactory; use PhpParser\Parser; @@ -40,7 +44,6 @@ use Symfony\Component\Cache\Adapter\ChainAdapter; use Symfony\Component\Cache\Adapter\TagAwareAdapter; use Symfony\Component\Cache\DependencyInjection\CachePoolPass; -use Symfony\Component\Cache\Marshaller\DefaultMarshaller; use Symfony\Component\Cache\Marshaller\MarshallerInterface; use Symfony\Component\Cache\ResettableInterface; use Symfony\Component\Clock\ClockInterface; @@ -73,7 +76,6 @@ use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\Glob; -use Symfony\Component\Form\ChoiceList\Factory\CachingFactoryDecorator; use Symfony\Component\Form\Extension\HtmlSanitizer\Type\TextTypeHtmlSanitizerExtension; use Symfony\Component\Form\Form; use Symfony\Component\Form\FormTypeExtensionInterface; @@ -93,8 +95,6 @@ use Symfony\Component\HttpKernel\Attribute\AsTargetedValueResolver; use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface; use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; -use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver; -use Symfony\Component\HttpKernel\Controller\ArgumentResolver\UidValueResolver; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -110,7 +110,6 @@ use Symfony\Component\Mercure\HubRegistry; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Bridge as MessengerBridge; -use Symfony\Component\Messenger\Command\StatsCommand; use Symfony\Component\Messenger\Handler\BatchHandlerInterface; use Symfony\Component\Messenger\MessageBus; use Symfony\Component\Messenger\MessageBusInterface; @@ -144,7 +143,6 @@ use Symfony\Component\RateLimiter\Storage\CacheStorage; use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer; use Symfony\Component\RemoteEvent\RemoteEvent; -use Symfony\Component\Routing\Loader\Psr4DirectoryLoader; use Symfony\Component\Scheduler\Attribute\AsSchedule; use Symfony\Component\Scheduler\Messenger\SchedulerTransportFactory; use Symfony\Component\Security\Core\AuthenticationEvents; @@ -161,10 +159,7 @@ use Symfony\Component\Serializer\Mapping\Loader\YamlFileLoader; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\Normalizer\ProblemNormalizer; -use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer; use Symfony\Component\Serializer\Serializer; -use Symfony\Component\Serializer\SerializerAwareInterface; use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Component\String\LazyString; use Symfony\Component\String\Slugger\SluggerInterface; @@ -176,12 +171,10 @@ use Symfony\Component\Translation\Translator; use Symfony\Component\Uid\Factory\UuidFactory; use Symfony\Component\Uid\UuidV4; -use Symfony\Component\Validator\Constraints\WhenValidator; use Symfony\Component\Validator\ConstraintValidatorInterface; use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader; use Symfony\Component\Validator\ObjectInitializerInterface; use Symfony\Component\Validator\Validation; -use Symfony\Component\Validator\ValidatorBuilder; use Symfony\Component\Webhook\Controller\WebhookController; use Symfony\Component\WebLink\HttpHeaderSerializer; use Symfony\Component\Workflow; @@ -219,11 +212,6 @@ public function load(array $configs, ContainerBuilder $container): void } $loader->load('web.php'); - - if (!class_exists(BackedEnumValueResolver::class)) { - $container->removeDefinition('argument_resolver.backed_enum_resolver'); - } - $loader->load('services.php'); $loader->load('fragment_renderer.php'); $loader->load('error_renderer.php'); @@ -388,6 +376,10 @@ public function load(array $configs, ContainerBuilder $container): void } $this->registerSerializerConfiguration($config['serializer'], $container, $loader); + + if (interface_exists(ExperimentalSerializerInterface::class)) { + $this->registerExperimentalSerializerConfiguration($config['serializer'], $container, $loader); + } } else { $container->getDefinition('argument_resolver.request_payload') ->setArguments([]) @@ -716,7 +708,6 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('routing.route_loader'); $container->setParameter('container.behavior_describing_tags', [ - 'annotations.cached_reader', 'container.do_not_inline', 'container.service_locator', 'container.service_subscriber', @@ -761,11 +752,6 @@ private function registerFormConfiguration(array $config, ContainerBuilder $cont if (!ContainerBuilder::willBeAvailable('symfony/translation', Translator::class, ['symfony/framework-bundle', 'symfony/form'])) { $container->removeDefinition('form.type_extension.upload.validator'); } - if (!method_exists(CachingFactoryDecorator::class, 'reset')) { - $container->getDefinition('form.choice_list_factory.cached') - ->clearTag('kernel.reset') - ; - } } private function registerHttpCacheConfiguration(array $config, ContainerBuilder $container, bool $httpMethodOverride): void @@ -1204,10 +1190,6 @@ private function registerRouterConfiguration(array $config, ContainerBuilder $co $container->getDefinition('router.request_context') ->replaceArgument(0, $config['default_uri']); } - - if (!class_exists(Psr4DirectoryLoader::class)) { - $container->removeDefinition('routing.loader.psr4'); - } } private function registerSessionConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void @@ -1232,10 +1214,15 @@ private function registerSessionConfiguration(array $config, ContainerBuilder $c $container->setParameter('session.storage.options', $options); // session handler (the internal callback registered with PHP session management) - if (null === $config['handler_id']) { + if (null === ($config['handler_id'] ?? $config['save_path'] ?? null)) { $config['save_path'] = null; $container->setAlias('session.handler', 'session.handler.native'); } else { + $config['handler_id'] ??= 'session.handler.native_file'; + + if (!\array_key_exists('save_path', $config)) { + $config['save_path'] = '%kernel.cache_dir%/sessions'; + } $container->resolveEnvPlaceholders($config['handler_id'], null, $usedEnvs); if ($usedEnvs || preg_match('#^[a-z]++://#', $config['handler_id'])) { @@ -1556,7 +1543,7 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder TranslationBridge\Crowdin\CrowdinProviderFactory::class => 'translation.provider_factory.crowdin', TranslationBridge\Loco\LocoProviderFactory::class => 'translation.provider_factory.loco', TranslationBridge\Lokalise\LokaliseProviderFactory::class => 'translation.provider_factory.lokalise', - PhraseProviderFactory::class => 'translation.provider_factory.phrase', + TranslationBridge\Phrase\PhraseProviderFactory::class => 'translation.provider_factory.phrase', ]; $parentPackages = ['symfony/framework-bundle', 'symfony/translation', 'symfony/http-client']; @@ -1636,8 +1623,8 @@ private function registerValidationConfiguration(array $config, ContainerBuilder $definition = $container->findDefinition('validator.email'); $definition->replaceArgument(0, $config['email_validation_mode']); - if (\array_key_exists('enable_annotations', $config) && $config['enable_annotations']) { - $validatorBuilder->addMethodCall('enableAnnotationMapping'); + if (\array_key_exists('enable_attributes', $config) && $config['enable_attributes']) { + $validatorBuilder->addMethodCall('enableAttributeMapping'); } if (\array_key_exists('static_method', $config) && $config['static_method']) { @@ -1664,10 +1651,6 @@ private function registerValidationConfiguration(array $config, ContainerBuilder if (!class_exists(ExpressionLanguage::class)) { $container->removeDefinition('validator.expression_language'); } - - if (!class_exists(WhenValidator::class)) { - $container->removeDefinition('validator.when'); - } } private function registerValidatorMapping(ContainerBuilder $container, array $config, array &$files): void @@ -1835,7 +1818,7 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder $container->removeDefinition('serializer.encoder.yaml'); } - if (!class_exists(UnwrappingDenormalizer::class) || !$this->isInitializedConfigEnabled('property_access')) { + if (!$this->isInitializedConfigEnabled('property_access')) { $container->removeDefinition('serializer.denormalizer.unwrapping'); } @@ -1843,26 +1826,17 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder $container->removeDefinition('serializer.normalizer.mime_message'); } - if (!class_exists(Translator::class)) { - $container->removeDefinition('serializer.normalizer.translatable'); + if ($container->getParameter('kernel.debug')) { + $container->removeDefinition('serializer.mapping.cache_class_metadata_factory'); } - // compat with Symfony < 6.3 - if (!is_subclass_of(ProblemNormalizer::class, SerializerAwareInterface::class)) { - $container->getDefinition('serializer.normalizer.problem') - ->setArguments(['%kernel.debug%']); + if (!class_exists(Translator::class)) { + $container->removeDefinition('serializer.normalizer.translatable'); } $serializerLoaders = []; - if (isset($config['enable_annotations']) && $config['enable_annotations']) { - if ($container->getParameter('kernel.debug')) { - $container->removeDefinition('serializer.mapping.cache_class_metadata_factory'); - } - - $annotationLoader = new Definition( - AnnotationLoader::class, - [new Reference('annotation_reader', ContainerInterface::NULL_ON_INVALID_REFERENCE)] - ); + if (isset($config['enable_attributes']) && $config['enable_attributes']) { + $annotationLoader = new Definition(AnnotationLoader::class); $serializerLoaders[] = $annotationLoader; } @@ -1923,6 +1897,42 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder } } + private function registerExperimentalSerializerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('serializer_experimental.php'); + + $container->setParameter('serializer.serializable_paths', $config['serializable_paths']); + $container->setParameter('serializer.formats', $config['formats']); + $container->setParameter('serializer.max_variants', $config['max_variants']); + + $container->setParameter('serializer.lazy_instantiation', $config['lazy_instantiation']); + $container->setParameter('serializer.lazy_deserialization', $config['lazy_deserialization']); + + $container->setAlias('serializer.instantiator', $config['lazy_instantiation'] ? 'serializer.instantiator.lazy' : 'serializer.instantiator.eager'); + $container->setAlias(InstantiatorInterface::class, 'serializer.instantiator'); + + foreach ($config['serializable_paths'] as $path) { + if (!is_dir($path)) { + continue; + } + + $container->fileExists($path, '/\.php$/'); + } + + if ( + ContainerBuilder::willBeAvailable('phpstan/phpdoc-parser', PhpDocParser::class, ['symfony/framework-bundle', 'symfony/serializer']) + && ContainerBuilder::willBeAvailable('phpdocumentor/type-resolver', ContextFactory::class, ['symfony/framework-bundle', 'symfony/serializer']) + ) { + $container->register('serializer.type_extractor.phpstan', PhpstanTypeExtractor::class) + ->setDecoratedService('serializer.type_extractor') + ->setArguments([ + new Reference('serializer.type_extractor.phpstan.inner'), + ]) + ->setLazy(true) + ->addTag('proxy', ['interface' => TypeExtractorInterface::class]); + } + } + private function registerPropertyInfoConfiguration(ContainerBuilder $container, PhpFileLoader $loader): void { if (!interface_exists(PropertyInfoExtractorInterface::class)) { @@ -2049,7 +2059,7 @@ private function registerMessengerConfiguration(array $config, ContainerBuilder throw new LogicException('Messenger support cannot be enabled as the Messenger component is not installed. Try running "composer require symfony/messenger".'); } - if (!$this->hasConsole() || !class_exists(StatsCommand::class)) { + if (!$this->hasConsole()) { $container->removeDefinition('console.command.messenger_stats'); } @@ -2288,10 +2298,6 @@ private function registerMessengerConfiguration(array $config, ContainerBuilder private function registerCacheConfiguration(array $config, ContainerBuilder $container): void { - if (!class_exists(DefaultMarshaller::class)) { - $container->removeDefinition('cache.default_marshaller'); - } - $version = new Parameter('container.build_id'); $container->getDefinition('cache.adapter.apcu')->replaceArgument(2, $version); $container->getDefinition('cache.adapter.system')->replaceArgument(2, $version); @@ -2352,16 +2358,10 @@ private function registerCacheConfiguration(array $config, ContainerBuilder $con $container->register($name, TagAwareAdapter::class) ->addArgument(new Reference('.'.$name.'.inner')) ->addArgument(true !== $pool['tags'] ? new Reference($pool['tags']) : null) + ->addMethodCall('setLogger', [new Reference('logger', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)]) ->setPublic($pool['public']) ->addTag('cache.taggable', ['pool' => $name]) - ; - - if (method_exists(TagAwareAdapter::class, 'setLogger')) { - $container - ->getDefinition($name) - ->addMethodCall('setLogger', [new Reference('logger', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)]) - ->addTag('monolog.logger', ['channel' => 'cache']); - } + ->addTag('monolog.logger', ['channel' => 'cache']); $pool['name'] = $tagAwareId = $name; $pool['public'] = false; @@ -2387,7 +2387,7 @@ private function registerCacheConfiguration(array $config, ContainerBuilder $con $container->setDefinition($name, $definition); } - if (method_exists(PropertyAccessor::class, 'createCache')) { + if (class_exists(PropertyAccessor::class)) { $propertyAccessDefinition = $container->register('cache.property_access', AdapterInterface::class); if (!$container->getParameter('kernel.debug')) { @@ -2432,20 +2432,16 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder $this->registerRetryableHttpClient($retryOptions, 'http_client', $container); } - if ($hasUriTemplate = class_exists(UriTemplateHttpClient::class)) { - if (ContainerBuilder::willBeAvailable('guzzlehttp/uri-template', \GuzzleHttp\UriTemplate\UriTemplate::class, [])) { - $container->setAlias('http_client.uri_template_expander', 'http_client.uri_template_expander.guzzle'); - } elseif (ContainerBuilder::willBeAvailable('rize/uri-template', \Rize\UriTemplate::class, [])) { - $container->setAlias('http_client.uri_template_expander', 'http_client.uri_template_expander.rize'); - } - - $container - ->getDefinition('http_client.uri_template') - ->setArgument(2, $defaultUriTemplateVars); - } elseif ($defaultUriTemplateVars) { - throw new LogicException('Support for URI template requires symfony/http-client 6.3 or higher, try upgrading.'); + if (ContainerBuilder::willBeAvailable('guzzlehttp/uri-template', \GuzzleHttp\UriTemplate\UriTemplate::class, [])) { + $container->setAlias('http_client.uri_template_expander', 'http_client.uri_template_expander.guzzle'); + } elseif (ContainerBuilder::willBeAvailable('rize/uri-template', \Rize\UriTemplate::class, [])) { + $container->setAlias('http_client.uri_template_expander', 'http_client.uri_template_expander.rize'); } + $container + ->getDefinition('http_client.uri_template') + ->setArgument(2, $defaultUriTemplateVars); + foreach ($config['scoped_clients'] as $name => $scopeConfig) { if ($container->has($name)) { throw new InvalidArgumentException(sprintf('Invalid scope name: "%s" is reserved.', $name)); @@ -2476,16 +2472,14 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder $this->registerRetryableHttpClient($retryOptions, $name, $container); } - if ($hasUriTemplate) { - $container - ->register($name.'.uri_template', UriTemplateHttpClient::class) - ->setDecoratedService($name, null, 7) // Between TraceableHttpClient (5) and RetryableHttpClient (10) - ->setArguments([ - new Reference($name.'.uri_template.inner'), - new Reference('http_client.uri_template_expander', ContainerInterface::NULL_ON_INVALID_REFERENCE), - $defaultUriTemplateVars, - ]); - } + $container + ->register($name.'.uri_template', UriTemplateHttpClient::class) + ->setDecoratedService($name, null, 7) // Between TraceableHttpClient (5) and RetryableHttpClient (10) + ->setArguments([ + new Reference($name.'.uri_template.inner'), + new Reference('http_client.uri_template_expander', ContainerInterface::NULL_ON_INVALID_REFERENCE), + $defaultUriTemplateVars, + ]); $container->registerAliasForArgument($name, HttpClientInterface::class); @@ -2593,6 +2587,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co $webhookRequestParsers = [ MailerBridge\Mailgun\Webhook\MailgunRequestParser::class => 'mailer.webhook.request_parser.mailgun', MailerBridge\Postmark\Webhook\PostmarkRequestParser::class => 'mailer.webhook.request_parser.postmark', + MailerBridge\Sendgrid\Webhook\SendgridRequestParser::class => 'mailer.webhook.request_parser.sendgrid', ]; foreach ($webhookRequestParsers as $class => $service) { @@ -2896,10 +2891,6 @@ private function registerUidConfiguration(array $config, ContainerBuilder $conta $container->getDefinition('name_based_uuid.factory') ->setArguments([$config['name_based_uuid_namespace']]); } - - if (!class_exists(UidValueResolver::class)) { - $container->removeDefinition('argument_resolver.uid'); - } } private function registerHtmlSanitizerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void diff --git a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php index cf195c522c73a..bda9db21bbe10 100644 --- a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php +++ b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php @@ -11,7 +11,6 @@ namespace Symfony\Bundle\FrameworkBundle; -use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\AddAnnotationsCachedReaderPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\AddDebugLogProcessorPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\AddExpressionLanguageProvidersPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\AssetsContextPass; @@ -60,6 +59,8 @@ use Symfony\Component\PropertyInfo\DependencyInjection\PropertyInfoPass; use Symfony\Component\Routing\DependencyInjection\RoutingResolverPass; use Symfony\Component\Scheduler\DependencyInjection\AddScheduleMessengerPass; +use Symfony\Component\Serializer\DependencyInjection\RuntimeSerializerServicesPass; +use Symfony\Component\Serializer\DependencyInjection\SerializablePass; use Symfony\Component\Serializer\DependencyInjection\SerializerPass; use Symfony\Component\Translation\DependencyInjection\TranslationDumperPass; use Symfony\Component\Translation\DependencyInjection\TranslationExtractorPass; @@ -142,7 +143,6 @@ public function build(ContainerBuilder $container): void // but as late as possible to get resolved parameters $container->addCompilerPass($registerListenersPass, PassConfig::TYPE_BEFORE_REMOVING); $this->addCompilerPassIfExists($container, AddConstraintValidatorsPass::class); - $container->addCompilerPass(new AddAnnotationsCachedReaderPass(), PassConfig::TYPE_AFTER_REMOVING, -255); $this->addCompilerPassIfExists($container, AddValidatorInitializersPass::class); $this->addCompilerPassIfExists($container, AddConsoleCommandPass::class, PassConfig::TYPE_BEFORE_REMOVING); // must be registered as late as possible to get access to all Twig paths registered in @@ -154,6 +154,9 @@ public function build(ContainerBuilder $container): void $this->addCompilerPassIfExists($container, TranslationExtractorPass::class); $this->addCompilerPassIfExists($container, TranslationDumperPass::class); $container->addCompilerPass(new FragmentRendererPass()); + // must be registered before the SerializerPass and RuntimeSerializerServicesPass + $this->addCompilerPassIfExists($container, SerializablePass::class, priority: 16); + $this->addCompilerPassIfExists($container, RuntimeSerializerServicesPass::class); $this->addCompilerPassIfExists($container, SerializerPass::class); $this->addCompilerPassIfExists($container, PropertyInfoPass::class); $container->addCompilerPass(new ControllerArgumentValueResolverPass()); diff --git a/src/Symfony/Bundle/FrameworkBundle/KernelBrowser.php b/src/Symfony/Bundle/FrameworkBundle/KernelBrowser.php index 794f4f454427d..cf6df700950f7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/KernelBrowser.php +++ b/src/Symfony/Bundle/FrameworkBundle/KernelBrowser.php @@ -95,12 +95,15 @@ public function enableReboot(): void } /** - * @param UserInterface $user + * @param UserInterface $user + * @param array $tokenAttributes * * @return $this */ - public function loginUser(object $user, string $firewallContext = 'main'): static + public function loginUser(object $user, string $firewallContext = 'main'/* , array $tokenAttributes = [] */): static { + $tokenAttributes = 2 < \func_num_args() ? func_get_arg(2) : []; + if (!interface_exists(UserInterface::class)) { throw new \LogicException(sprintf('"%s" requires symfony/security-core to be installed. Try running "composer require symfony/security-core".', __METHOD__)); } @@ -110,6 +113,7 @@ public function loginUser(object $user, string $firewallContext = 'main'): stati } $token = new TestBrowserToken($user->getRoles(), $user, $firewallContext); + $token->setAttributes($tokenAttributes); $container = $this->getContainer(); $container->get('security.untracked_token_storage')->setToken($token); diff --git a/src/Symfony/Bundle/FrameworkBundle/README.md b/src/Symfony/Bundle/FrameworkBundle/README.md index 402d8718d4875..14c600facfd71 100644 --- a/src/Symfony/Bundle/FrameworkBundle/README.md +++ b/src/Symfony/Bundle/FrameworkBundle/README.md @@ -7,15 +7,7 @@ Symfony full-stack framework. Sponsor ------- -The FrameworkBundle for Symfony 6.3 is [backed][1] by [alximy][2]. - -A team of passionate humans from very different backgrounds, sharing their love of -PHP, Symfony and its ecosystem. Their CTO, Expert developers, tech leads, can help -you learn or develop the tools you need, and perform audits or tailored workshops. -They value contributing to the Open Source community and are willing to mentor new -contributors in their team or yours. - -Help Symfony by [sponsoring][3] its development! +Help Symfony by [sponsoring][1] its development! Resources --------- @@ -25,6 +17,4 @@ Resources [send Pull Requests](https://github.com/symfony/symfony/pulls) in the [main Symfony repository](https://github.com/symfony/symfony) -[1]: https://symfony.com/backers -[2]: https://alximy.io/ [3]: https://symfony.com/sponsor diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php index b73d70fcfcd11..c59424db9c661 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php @@ -19,6 +19,7 @@ use Symfony\Component\AssetMapper\Command\AssetMapperCompileCommand; use Symfony\Component\AssetMapper\Command\DebugAssetMapperCommand; use Symfony\Component\AssetMapper\Command\ImportMapExportCommand; +use Symfony\Component\AssetMapper\Command\ImportMapInstallCommand; use Symfony\Component\AssetMapper\Command\ImportMapRemoveCommand; use Symfony\Component\AssetMapper\Command\ImportMapRequireCommand; use Symfony\Component\AssetMapper\Command\ImportMapUpdateCommand; @@ -142,6 +143,7 @@ abstract_arg('importmap.php path'), abstract_arg('vendor directory'), service('asset_mapper.importmap.resolver'), + service('http_client'), ]) ->alias(ImportMapManager::class, 'asset_mapper.importmap.manager') @@ -202,5 +204,9 @@ ->set('asset_mapper.importmap.command.export', ImportMapExportCommand::class) ->args([service('asset_mapper.importmap.manager')]) ->tag('console.command') + + ->set('asset_mapper.importmap.command.install', ImportMapInstallCommand::class) + ->args([service('asset_mapper.importmap.manager')]) + ->tag('console.command') ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php index 7780b3df51e78..30ea50dade127 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php @@ -15,6 +15,8 @@ use Symfony\Component\Mailer\Bridge\Mailgun\Webhook\MailgunRequestParser; use Symfony\Component\Mailer\Bridge\Postmark\RemoteEvent\PostmarkPayloadConverter; use Symfony\Component\Mailer\Bridge\Postmark\Webhook\PostmarkRequestParser; +use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter; +use Symfony\Component\Mailer\Bridge\Sendgrid\Webhook\SendgridRequestParser; return static function (ContainerConfigurator $container) { $container->services() @@ -27,5 +29,10 @@ ->set('mailer.webhook.request_parser.postmark', PostmarkRequestParser::class) ->args([service('mailer.payload_converter.postmark')]) ->alias(PostmarkRequestParser::class, 'mailer.webhook.request_parser.postmark') + + ->set('mailer.payload_converter.sendgrid', SendgridPayloadConverter::class) + ->set('mailer.webhook.request_parser.sendgrid', SendgridRequestParser::class) + ->args([service('mailer.payload_converter.sendgrid')]) + ->alias(SendgridRequestParser::class, 'mailer.webhook.request_parser.sendgrid') ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index fc4fd99ffd93c..c3f2f967074e4 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -266,7 +266,7 @@ - + @@ -319,7 +319,7 @@ - + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer_experimental.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer_experimental.php new file mode 100644 index 0000000000000..af748428ce214 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer_experimental.php @@ -0,0 +1,220 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Bundle\FrameworkBundle\CacheWarmer\SerializerLazyGhostCacheWarmer; +use Symfony\Bundle\FrameworkBundle\CacheWarmer\SerializerTemplateCacheWarmer; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelBuilder as DeserializeDataModelBuilder; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelBuilderInterface as DeserializeDataModelBuilderInterface; +use Symfony\Component\Serializer\Deserialize\Decoder\CsvDecoder; +use Symfony\Component\Serializer\Deserialize\Decoder\JsonDecoder; +use Symfony\Component\Serializer\Deserialize\Deserializer; +use Symfony\Component\Serializer\Deserialize\DeserializerInterface; +use Symfony\Component\Serializer\Deserialize\Instantiator\EagerInstantiator; +use Symfony\Component\Serializer\Deserialize\Instantiator\LazyInstantiator; +use Symfony\Component\Serializer\Deserialize\Mapping\AttributePropertyMetadataLoader as DeserializeAttributePropertyMetadataLoader; +use Symfony\Component\Serializer\Deserialize\Mapping\PropertyMetadataLoader as DeserializePropertyMetadataLoader; +use Symfony\Component\Serializer\Deserialize\Mapping\PropertyMetadataLoaderInterface as DeserializePropertyMetadataLoaderInterface; +use Symfony\Component\Serializer\Deserialize\Mapping\TypePropertyMetadataLoader as DeserializeTypePropertyMetadataLoader; +use Symfony\Component\Serializer\Deserialize\Splitter\JsonSplitter; +use Symfony\Component\Serializer\Deserialize\Template\EagerTemplateGenerator; +use Symfony\Component\Serializer\Deserialize\Template\LazyTemplateGenerator; +use Symfony\Component\Serializer\Deserialize\Template\Template as DeserializeTemplate; +use Symfony\Component\Serializer\Serialize\DataModel\DataModelBuilder as SerializeDataModelBuilder; +use Symfony\Component\Serializer\Serialize\DataModel\DataModelBuilderInterface as SerializeDataModelBuilderInterface; +use Symfony\Component\Serializer\Serialize\Encoder\CsvEncoder; +use Symfony\Component\Serializer\Serialize\Mapping\AttributePropertyMetadataLoader as SerializeAttributePropertyMetadataLoader; +use Symfony\Component\Serializer\Serialize\Mapping\PropertyMetadataLoader as SerializePropertyMetadataLoader; +use Symfony\Component\Serializer\Serialize\Mapping\PropertyMetadataLoaderInterface as SerializePropertyMetadataLoaderInterface; +use Symfony\Component\Serializer\Serialize\Mapping\TypePropertyMetadataLoader as SerializeTypePropertyMetadataLoader; +use Symfony\Component\Serializer\Serialize\Serializer; +use Symfony\Component\Serializer\Serialize\SerializerInterface; +use Symfony\Component\Serializer\Serialize\Template\JsonTemplateGenerator as SerializeJsonTemplateGenerator; +use Symfony\Component\Serializer\Serialize\Template\NormalizerEncoderTemplateGenerator; +use Symfony\Component\Serializer\Serialize\Template\Template as SerializeTemplate; +use Symfony\Component\Serializer\Template\TemplateVariationExtractor; +use Symfony\Component\Serializer\Template\TemplateVariationExtractorInterface; +use Symfony\Component\Serializer\Type\ReflectionTypeExtractor; +use Symfony\Component\Serializer\Type\TypeExtractorInterface; + +return static function (ContainerConfigurator $container) { + $container->parameters() + ->set('.serializer.cache_dir.template', '%kernel.cache_dir%/serializer/template') + ->set('.serializer.cache_dir.lazy_ghost', '%kernel.cache_dir%/serializer/lazy_ghost') + ; + + $container->services() + // Serializer/Deserializer + ->set('serializer.serializer', Serializer::class) + ->args([ + service('serializer.serialize.template'), + abstract_arg('serialize/deserialize runtime services'), + param('.serializer.cache_dir.template'), + ]) + ->alias(SerializerInterface::class, 'serializer.serializer') + + ->set('serializer.deserializer', Deserializer::class) + ->args([ + service('serializer.deserialize.template'), + abstract_arg('serialize/deserialize runtime services'), + service('serializer.instantiator'), + param('.serializer.cache_dir.template'), + ]) + ->alias(DeserializerInterface::class, 'serializer.deserializer') + + // Template + ->set('serializer.serialize.template', SerializeTemplate::class) + ->args([ + service('serializer.template_variation_extractor'), + service('serializer.serialize.data_model_builder'), + abstract_arg('serialize template generators'), + param('.serializer.cache_dir.template'), + ]) + + ->set('serializer.deserialize.template', DeserializeTemplate::class) + ->args([ + service('serializer.template_variation_extractor'), + service('serializer.deserialize.data_model_builder'), + abstract_arg('deserialize template generators'), + param('.serializer.cache_dir.template'), + param('serializer.lazy_deserialization'), + ]) + + // Template variations + ->set('serializer.template_variation_extractor', TemplateVariationExtractor::class) + ->alias(TemplateVariationExtractorInterface::class, 'serializer.template_variation_extractor') + + // Template generators + ->set('serializer.serialize.template_generator.json', SerializeJsonTemplateGenerator::class) + ->tag('serializer.serialize.template_generator', ['format' => 'json']) + + ->set('serializer.serialize.template_generator.csv', NormalizerEncoderTemplateGenerator::class) + ->args([ + CsvEncoder::class, + ]) + ->tag('serializer.serialize.template_generator', ['format' => 'csv']) + + ->set('serializer.deserialize.template_generator.json.eager', EagerTemplateGenerator::class) + ->args([ + JsonDecoder::class, + ]) + ->tag('serializer.deserialize.template_generator.eager', ['format' => 'json']) + + ->set('serializer.deserialize.template_generator.json.lazy', LazyTemplateGenerator::class) + ->args([ + JsonDecoder::class, + JsonSplitter::class, + ]) + ->tag('serializer.deserialize.template_generator.lazy', ['format' => 'json']) + + ->set('serializer.deserialize.template_generator.csv.eager', EagerTemplateGenerator::class) + ->args([ + CsvDecoder::class, + ]) + ->tag('serializer.deserialize.template_generator.eager', ['format' => 'csv']) + + // Data model + ->set('serializer.serialize.data_model_builder', SerializeDataModelBuilder::class) + ->args([ + service('serializer.serialize.metadata.property_loader'), + abstract_arg('serialize/deserialize runtime services'), + ]) + ->alias(SerializeDataModelBuilderInterface::class, 'serializer.serialize.data_model_builder') + + ->set('serializer.deserialize.data_model_builder', DeserializeDataModelBuilder::class) + ->args([ + service('serializer.deserialize.metadata.property_loader'), + abstract_arg('serialize/deserialize runtime services'), + ]) + ->alias(DeserializeDataModelBuilderInterface::class, 'serializer.deserialize.data_model_builder') + + // Instantiators + ->set('serializer.instantiator.eager', EagerInstantiator::class) + + ->set('serializer.instantiator.lazy', LazyInstantiator::class) + ->args([ + param('.serializer.cache_dir.lazy_ghost'), + ]) + + // Metadata + ->set('serializer.serialize.metadata.property_loader', SerializePropertyMetadataLoader::class) + ->args([ + service('serializer.type_extractor'), + ]) + + ->set('serializer.serialize.metadata.property_loader.attribute', SerializeAttributePropertyMetadataLoader::class) + ->decorate('serializer.serialize.metadata.property_loader') + ->args([ + service('serializer.serialize.metadata.property_loader.attribute.inner'), + service('serializer.type_extractor'), + ]) + + ->set('serializer.serialize.metadata.property_loader.type', SerializeTypePropertyMetadataLoader::class) + ->decorate('serializer.serialize.metadata.property_loader') + ->args([ + service('serializer.serialize.metadata.property_loader.type.inner'), + service('serializer.type_extractor'), + ]) + + ->alias(SerializePropertyMetadataLoaderInterface::class, 'serializer.serialize.metadata.property_loader') + + ->set('serializer.deserialize.metadata.property_loader', DeserializePropertyMetadataLoader::class) + ->args([ + service('serializer.type_extractor'), + ]) + + ->set('serializer.deserialize.metadata.property_loader.attribute', DeserializeAttributePropertyMetadataLoader::class) + ->decorate('serializer.deserialize.metadata.property_loader') + ->args([ + service('serializer.deserialize.metadata.property_loader.attribute.inner'), + service('serializer.type_extractor'), + ]) + + ->set('serializer.deserialize.metadata.property_loader.type', DeserializeTypePropertyMetadataLoader::class) + ->decorate('serializer.deserialize.metadata.property_loader') + ->args([ + service('serializer.deserialize.metadata.property_loader.type.inner'), + service('serializer.type_extractor'), + ]) + + ->alias(DeserializePropertyMetadataLoaderInterface::class, 'serializer.deserialize.metadata.property_loader') + + // Type extractors + ->set('serializer.type_extractor.reflection', ReflectionTypeExtractor::class) + ->lazy() + ->tag('proxy', ['interface' => TypeExtractorInterface::class]) + + ->alias('serializer.type_extractor', 'serializer.type_extractor.reflection') + ->alias(TypeExtractorInterface::class, 'serializer.type_extractor') + + // Cache + ->set('serializer.cache_warmer.template', SerializerTemplateCacheWarmer::class) + ->args([ + abstract_arg('serializable types'), + service('serializer.serialize.template'), + service('serializer.deserialize.template'), + service('serializer.template_variation_extractor'), + param('.serializer.cache_dir.template'), + param('serializer.formats'), + param('serializer.max_variants'), + service('logger')->ignoreOnInvalid(), + ]) + ->tag('kernel.cache_warmer') + + ->set('serializer.cache_warmer.lazy_ghost', SerializerLazyGhostCacheWarmer::class) + ->args([ + abstract_arg('serializable types'), + param('.serializer.cache_dir.lazy_ghost'), + ]) + ->tag('kernel.cache_warmer') + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/SerializerLazyGhostCacheWarmerTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/SerializerLazyGhostCacheWarmerTest.php new file mode 100644 index 0000000000000..a5aae7673424e --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/SerializerLazyGhostCacheWarmerTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\CacheWarmer; + +use Symfony\Bundle\FrameworkBundle\CacheWarmer\SerializerLazyGhostCacheWarmer; +use Symfony\Bundle\FrameworkBundle\Tests\TestCase; +use Symfony\Component\Serializer\Serialize\SerializerInterface; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; + +class SerializerLazyGhostCacheWarmerTest extends TestCase +{ + private string $cacheDir; + + protected function setUp(): void + { + parent::setUp(); + + if (!interface_exists(SerializerInterface::class)) { + $this->markTestSkipped('experimental version of symfony/serializer is required'); + } + + $this->cacheDir = sprintf('%s/symfony_serializer_lazy_ghost', sys_get_temp_dir()); + + if (is_dir($this->cacheDir)) { + array_map('unlink', glob($this->cacheDir.'/*')); + rmdir($this->cacheDir); + } + } + + public function testWarmUpLazyGhost() + { + (new SerializerLazyGhostCacheWarmer([ClassicDummy::class, 'int'], $this->cacheDir))->warmUp('useless'); + + $this->assertSame( + array_map(fn (string $c): string => sprintf('%s/%s.php', $this->cacheDir, hash('xxh128', $c)), [ClassicDummy::class]), + glob($this->cacheDir.'/*'), + ); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/SerializerTemplateCacheWarmerTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/SerializerTemplateCacheWarmerTest.php new file mode 100644 index 0000000000000..5e05f06c92187 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/SerializerTemplateCacheWarmerTest.php @@ -0,0 +1,159 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\CacheWarmer; + +use Psr\Log\NullLogger; +use Symfony\Bundle\FrameworkBundle\CacheWarmer\SerializerTemplateCacheWarmer; +use Symfony\Bundle\FrameworkBundle\Tests\TestCase; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelBuilderInterface as DeserializeDataModelBuilderInterface; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelNodeInterface as DeserializeDataModelNodeInterface; +use Symfony\Component\Serializer\Deserialize\Template\Template as DeserializeTemplate; +use Symfony\Component\Serializer\Deserialize\Template\TemplateGeneratorInterface as DeserializeTemplateGeneratorInterface; +use Symfony\Component\Serializer\Serialize\DataModel\DataModelBuilderInterface as SerializeDataModelBuilderInterface; +use Symfony\Component\Serializer\Serialize\DataModel\DataModelNodeInterface as SerializeDataModelNodeInterface; +use Symfony\Component\Serializer\Serialize\SerializerInterface; +use Symfony\Component\Serializer\Serialize\Template\Template as SerializeTemplate; +use Symfony\Component\Serializer\Serialize\Template\TemplateGeneratorInterface as SerializeTemplateGeneratorInterface; +use Symfony\Component\Serializer\Template\TemplateVariationExtractor; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithGroups; +use Symfony\Component\Serializer\Type\Type; + +class SerializerTemplateCacheWarmerTest extends TestCase +{ + private string $cacheDir; + + protected function setUp(): void + { + parent::setUp(); + + if (!interface_exists(SerializerInterface::class)) { + $this->markTestSkipped('experimental version of symfony/serializer is required'); + } + + $this->cacheDir = sprintf('%s/symfony_serializer_template', sys_get_temp_dir()); + + if (is_dir($this->cacheDir)) { + array_map('unlink', glob($this->cacheDir.'/*')); + rmdir($this->cacheDir); + } + } + + public function testWarmUpTemplates() + { + $this->cacheWarmer([ClassicDummy::class], ['foo'])->warmUp('useless'); + + $this->assertSame([ + sprintf('%s/c13f5526678495e20da82e0a7c1c300b.deserialize.eager.foo.php', $this->cacheDir), + sprintf('%s/c13f5526678495e20da82e0a7c1c300b.serialize.foo.php', $this->cacheDir), + ], glob($this->cacheDir.'/*')); + } + + public function testWarmUpTemplateWithMultipleFormats() + { + $this->cacheWarmer([ClassicDummy::class], ['foo', 'bar'])->warmUp('useless'); + + $this->assertSame([ + sprintf('%s/c13f5526678495e20da82e0a7c1c300b.deserialize.eager.bar.php', $this->cacheDir), + sprintf('%s/c13f5526678495e20da82e0a7c1c300b.deserialize.eager.foo.php', $this->cacheDir), + sprintf('%s/c13f5526678495e20da82e0a7c1c300b.serialize.bar.php', $this->cacheDir), + sprintf('%s/c13f5526678495e20da82e0a7c1c300b.serialize.foo.php', $this->cacheDir), + ], glob($this->cacheDir.'/*')); + } + + public function testWarmUpTemplateWithGroupsVariants() + { + $this->cacheWarmer([DummyWithGroups::class], ['foo'], 32)->warmUp('useless'); + + $this->assertSame([ + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.42bacc6e7c63de24830ff243836b6ce5.deserialize.eager.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.42bacc6e7c63de24830ff243836b6ce5.serialize.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.613dc1622a961367df54936cc0b16b1c.deserialize.eager.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.613dc1622a961367df54936cc0b16b1c.serialize.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.d43c98b6d28073d6496d6801c2a33116.deserialize.eager.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.d43c98b6d28073d6496d6801c2a33116.serialize.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.deserialize.eager.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.ec0c1cfa27836dcd1e4380f64457b33c.deserialize.eager.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.ec0c1cfa27836dcd1e4380f64457b33c.serialize.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.ec63e403ed2a277f78a6fb538c960e45.deserialize.eager.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.ec63e403ed2a277f78a6fb538c960e45.serialize.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.eed85c52626ddd6155fcc5a720f6e73a.deserialize.eager.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.eed85c52626ddd6155fcc5a720f6e73a.serialize.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.f6aed1561b42f63fed1657d6430523e2.deserialize.eager.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.f6aed1561b42f63fed1657d6430523e2.serialize.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.serialize.foo.php', $this->cacheDir), + ], glob($this->cacheDir.'/*')); + } + + public function testWarmUpTemplateSliceWhenTooManyVariants() + { + $this->cacheWarmer([DummyWithGroups::class], ['foo'], 3)->warmUp('useless'); + + $this->assertSame([ + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.deserialize.eager.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.ec63e403ed2a277f78a6fb538c960e45.deserialize.eager.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.ec63e403ed2a277f78a6fb538c960e45.serialize.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.f6aed1561b42f63fed1657d6430523e2.deserialize.eager.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.f6aed1561b42f63fed1657d6430523e2.serialize.foo.php', $this->cacheDir), + sprintf('%s/16eccb6414dc03933f4799de52d9f6a8.serialize.foo.php', $this->cacheDir), + ], glob($this->cacheDir.'/*')); + } + + /** + * @param list $serializable + * @param list $formats + */ + private function cacheWarmer(array $serializable, array $formats, int $maxVariants = 32): SerializerTemplateCacheWarmer + { + $templateVariationExtractor = new TemplateVariationExtractor(); + + $serializeTemplateGenerator = $this->createStub(SerializeTemplateGeneratorInterface::class); + $deserializeTemplateGenerator = $this->createStub(DeserializeTemplateGeneratorInterface::class); + + $serializeDataModelNode = $this->createStub(SerializeDataModelNodeInterface::class); + $serializeDataModelNode->method('type')->willReturn(Type::int()); + + $deserializeDataModelNode = $this->createStub(DeserializeDataModelNodeInterface::class); + $deserializeDataModelNode->method('type')->willReturn(Type::int()); + + $serializeDataModelBuilder = $this->createStub(SerializeDataModelBuilderInterface::class); + $serializeDataModelBuilder->method('build')->willReturn($serializeDataModelNode); + + $deserializeDataModelBuilder = $this->createStub(DeserializeDataModelBuilderInterface::class); + $deserializeDataModelBuilder->method('build')->willReturn($deserializeDataModelNode); + + return new SerializerTemplateCacheWarmer( + $serializable, + new SerializeTemplate( + $templateVariationExtractor, + $serializeDataModelBuilder, + ['foo' => $serializeTemplateGenerator, 'bar' => $serializeTemplateGenerator, ], + $this->cacheDir, + ), + new DeserializeTemplate( + $templateVariationExtractor, + $deserializeDataModelBuilder, + [ + 'foo' => ['eager' => $deserializeTemplateGenerator, 'lazy' => $deserializeTemplateGenerator, ], + 'bar' => ['eager' => $deserializeTemplateGenerator, ], + ], + $this->cacheDir, + false, + ), + $templateVariationExtractor, + $this->cacheDir, + $formats, + $maxVariants, + new NullLogger(), + ); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/ValidatorCacheWarmerTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/ValidatorCacheWarmerTest.php index 92ef379b1b819..cc471e43fc685 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/ValidatorCacheWarmerTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/ValidatorCacheWarmerTest.php @@ -26,7 +26,7 @@ public function testWarmUp() $validatorBuilder->addXmlMapping(__DIR__.'/../Fixtures/Validation/Resources/person.xml'); $validatorBuilder->addYamlMapping(__DIR__.'/../Fixtures/Validation/Resources/author.yml'); $validatorBuilder->addMethodMapping('loadValidatorMetadata'); - $validatorBuilder->enableAnnotationMapping(); + $validatorBuilder->enableAttributeMapping(); $file = sys_get_temp_dir().'/cache-validator.php'; @unlink($file); @@ -46,7 +46,7 @@ public function testWarmUpWithAnnotations() { $validatorBuilder = new ValidatorBuilder(); $validatorBuilder->addYamlMapping(__DIR__.'/../Fixtures/Validation/Resources/categories.yml'); - $validatorBuilder->enableAnnotationMapping(); + $validatorBuilder->enableAttributeMapping(); $file = sys_get_temp_dir().'/cache-validator-with-annotations.php'; @unlink($file); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 781fabe7b301a..5006392c7a3cf 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -591,7 +591,7 @@ protected static function getBundleDefaultConfig() ], 'validation' => [ 'enabled' => !class_exists(FullStack::class), - 'enable_annotations' => !class_exists(FullStack::class), + 'enable_attributes' => !class_exists(FullStack::class), 'static_method' => ['loadValidatorMetadata'], 'translation_domain' => 'validators', 'mapping' => [ @@ -602,6 +602,7 @@ protected static function getBundleDefaultConfig() 'enabled' => true, 'endpoint' => null, ], + 'email_validation_mode' => 'html5', ], 'annotations' => [ 'enabled' => false, @@ -609,8 +610,13 @@ protected static function getBundleDefaultConfig() 'serializer' => [ 'default_context' => ['foo' => 'bar', JsonDecode::DETAILED_ERROR_MESSAGES => true], 'enabled' => true, - 'enable_annotations' => !class_exists(FullStack::class), + 'enable_attributes' => !class_exists(FullStack::class), 'mapping' => ['paths' => []], + 'serializable_paths' => [], + 'formats' => ['json'], + 'max_variants' => 32, + 'lazy_deserialization' => false, + 'lazy_instantiation' => false, ], 'property_access' => [ 'enabled' => true, @@ -635,11 +641,10 @@ protected static function getBundleDefaultConfig() 'session' => [ 'enabled' => false, 'storage_factory_id' => 'session.storage.factory.native', - 'handler_id' => 'session.handler.native_file', 'cookie_httponly' => true, - 'cookie_samesite' => null, + 'cookie_samesite' => 'lax', + 'cookie_secure' => 'auto', 'gc_probability' => 1, - 'save_path' => '%kernel.cache_dir%/sessions', 'metadata_update_threshold' => 0, ], 'request' => [ @@ -762,9 +767,9 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor ], 'uid' => [ 'enabled' => !class_exists(FullStack::class) && class_exists(UuidFactory::class), - 'default_uuid_version' => 6, + 'default_uuid_version' => 7, 'name_based_uuid_version' => 5, - 'time_based_uuid_version' => 6, + 'time_based_uuid_version' => 7, ], 'html_sanitizer' => [ 'enabled' => !class_exists(FullStack::class) && class_exists(HtmlSanitizer::class), diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php index b480ef9f0e3b6..b5d8061e4d0af 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php @@ -63,7 +63,7 @@ 'annotations' => false, 'serializer' => [ 'enabled' => true, - 'enable_annotations' => true, + 'enable_attributes' => true, 'name_converter' => 'serializer.name_converter.camel_case_to_snake_case', 'circular_reference_handler' => 'my.circular.reference.handler', 'max_depth_handler' => 'my.max.depth.handler', diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/serializer_mapping.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/serializer_mapping.php index c6a1636dbb31d..1e3d1ab2b9cf3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/serializer_mapping.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/serializer_mapping.php @@ -6,7 +6,7 @@ 'php_errors' => ['log' => true], 'annotations' => false, 'serializer' => [ - 'enable_annotations' => true, + 'enable_attributes' => true, 'mapping' => [ 'paths' => [ '%kernel.project_dir%/Fixtures/TestBundle/Resources/config/serializer_mapping/files', diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/serializer_mapping_without_annotations.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/serializer_mapping_without_annotations.php index 8f86200686b6a..3e203028ce2ac 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/serializer_mapping_without_annotations.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/serializer_mapping_without_annotations.php @@ -6,7 +6,7 @@ 'handle_all_throwables' => true, 'php_errors' => ['log' => true], 'serializer' => [ - 'enable_annotations' => false, + 'enable_attributes' => false, 'mapping' => [ 'paths' => [ '%kernel.project_dir%/Fixtures/TestBundle/Resources/config/serializer_mapping/files', diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/validation_annotations.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/validation_attributes.php similarity index 91% rename from src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/validation_annotations.php rename to src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/validation_attributes.php index f8298cc05f0c1..3e6ae75473060 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/validation_annotations.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/validation_attributes.php @@ -8,7 +8,7 @@ 'secret' => 's3cr3t', 'validation' => [ 'enabled' => true, - 'enable_annotations' => true, + 'enable_attributes' => true, 'email_validation_mode' => 'html5', ], ]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml index 88baf0d046beb..92e4405a003fd 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml @@ -34,7 +34,7 @@ - + true diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/serializer_mapping.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/serializer_mapping.xml index f3598b2a0e2e9..165669fe6d1de 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/serializer_mapping.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/serializer_mapping.xml @@ -7,7 +7,7 @@ - + %kernel.project_dir%/Fixtures/TestBundle/Resources/config/serializer_mapping/files %kernel.project_dir%/Fixtures/TestBundle/Resources/config/serializer_mapping/serialization.yml diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/serializer_mapping_without_annotations.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/serializer_mapping_without_annotations.xml index b33f7b3136d01..bb8dccf9c3d62 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/serializer_mapping_without_annotations.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/serializer_mapping_without_annotations.xml @@ -7,7 +7,7 @@ - + %kernel.project_dir%/Fixtures/TestBundle/Resources/config/serializer_mapping/files %kernel.project_dir%/Fixtures/TestBundle/Resources/config/serializer_mapping/serialization.yml diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/validation_annotations.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/validation_attributes.xml similarity index 88% rename from src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/validation_annotations.xml rename to src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/validation_attributes.xml index 8c7bbd02baf21..fe269612a75be 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/validation_annotations.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/validation_attributes.xml @@ -9,7 +9,7 @@ - + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml index 9353e070670aa..883e9d6c20ebb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml @@ -53,7 +53,7 @@ framework: annotations: false serializer: enabled: true - enable_annotations: true + enable_attributes: true name_converter: serializer.name_converter.camel_case_to_snake_case circular_reference_handler: my.circular.reference.handler max_depth_handler: my.max.depth.handler diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/serializer_mapping.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/serializer_mapping.yml index 421103a21fc6b..b2966b0edc86e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/serializer_mapping.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/serializer_mapping.yml @@ -5,7 +5,7 @@ framework: log: true annotations: false serializer: - enable_annotations: true + enable_attributes: true mapping: paths: - "%kernel.project_dir%/Fixtures/TestBundle/Resources/config/serializer_mapping/files" diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/serializer_mapping_without_annotations.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/serializer_mapping_without_annotations.yml index c946d26439294..46425dc942932 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/serializer_mapping_without_annotations.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/serializer_mapping_without_annotations.yml @@ -5,7 +5,7 @@ framework: php_errors: log: true serializer: - enable_annotations: false + enable_attributes: false mapping: paths: - "%kernel.project_dir%/Fixtures/TestBundle/Resources/config/serializer_mapping/files" diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/validation_annotations.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/validation_attributes.yml similarity index 90% rename from src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/validation_annotations.yml rename to src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/validation_attributes.yml index 0c09405533e75..2b62f8a3ec976 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/validation_annotations.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/validation_attributes.yml @@ -7,7 +7,7 @@ framework: secret: s3cr3t validation: enabled: true - enable_annotations: true + enable_attributes: true email_validation_mode: html5 services: diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php index 0d214ee207540..fa60d5c226bc1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php @@ -14,7 +14,6 @@ use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerAwareInterface; use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; -use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\AddAnnotationsCachedReaderPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\FrameworkExtension; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Messenger\DummyMessage; @@ -81,7 +80,6 @@ use Symfony\Component\Validator\DependencyInjection\AddConstraintValidatorsPass; use Symfony\Component\Validator\Validation; use Symfony\Component\Validator\Validator\ValidatorInterface; -use Symfony\Component\Validator\ValidatorBuilder; use Symfony\Component\Webhook\Client\RequestParser; use Symfony\Component\Webhook\Controller\WebhookController; use Symfony\Component\Workflow; @@ -1209,7 +1207,7 @@ public function testValidation() $this->assertSame([$xmlMappings], $calls[3][1]); $i = 3; if ($annotations) { - $this->assertSame('enableAnnotationMapping', $calls[++$i][0]); + $this->assertSame('enableAttributeMapping', $calls[++$i][0]); } $this->assertSame('addMethodMapping', $calls[++$i][0]); $this->assertSame(['loadValidatorMetadata'], $calls[$i][1]); @@ -1219,7 +1217,7 @@ public function testValidation() public function testValidationService() { - $container = $this->createContainerFromFile('validation_annotations', ['kernel.charset' => 'UTF-8'], false); + $container = $this->createContainerFromFile('validation_attributes', ['kernel.charset' => 'UTF-8'], false); $this->assertInstanceOf(ValidatorInterface::class, $container->get('validator.alias')); } @@ -1247,14 +1245,14 @@ public function testFileLinkFormat() $this->assertEquals('file%link%format', $container->getParameter('debug.file_link_format')); } - public function testValidationAnnotations() + public function testValidationAttributes() { - $container = $this->createContainerFromFile('validation_annotations'); + $container = $this->createContainerFromFile('validation_attributes'); $calls = $container->getDefinition('validator.builder')->getMethodCalls(); $this->assertCount(7, $calls); - $this->assertSame('enableAnnotationMapping', $calls[4][0]); + $this->assertSame('enableAttributeMapping', $calls[4][0]); $this->assertSame('addMethodMapping', $calls[5][0]); $this->assertSame(['loadValidatorMetadata'], $calls[5][1]); $this->assertSame('setMappingCache', $calls[6][0]); @@ -1266,7 +1264,7 @@ public function testValidationPaths() { require_once __DIR__.'/Fixtures/TestBundle/TestBundle.php'; - $container = $this->createContainerFromFile('validation_annotations', [ + $container = $this->createContainerFromFile('validation_attributes', [ 'kernel.bundles' => ['TestBundle' => 'Symfony\\Bundle\\FrameworkBundle\\Tests\\TestBundle'], 'kernel.bundles_metadata' => ['TestBundle' => ['namespace' => 'Symfony\\Bundle\\FrameworkBundle\\Tests', 'path' => __DIR__.'/Fixtures/TestBundle']], ]); @@ -1276,7 +1274,7 @@ public function testValidationPaths() $this->assertCount(8, $calls); $this->assertSame('addXmlMappings', $calls[3][0]); $this->assertSame('addYamlMappings', $calls[4][0]); - $this->assertSame('enableAnnotationMapping', $calls[5][0]); + $this->assertSame('enableAttributeMapping', $calls[5][0]); $this->assertSame('addMethodMapping', $calls[6][0]); $this->assertSame(['loadValidatorMetadata'], $calls[6][1]); $this->assertSame('setMappingCache', $calls[7][0]); @@ -1302,7 +1300,7 @@ public function testValidationPathsUsingCustomBundlePath() { require_once __DIR__.'/Fixtures/CustomPathBundle/src/CustomPathBundle.php'; - $container = $this->createContainerFromFile('validation_annotations', [ + $container = $this->createContainerFromFile('validation_attributes', [ 'kernel.bundles' => ['CustomPathBundle' => 'Symfony\\Bundle\\FrameworkBundle\\Tests\\CustomPathBundle'], 'kernel.bundles_metadata' => ['TestBundle' => ['namespace' => 'Symfony\\Bundle\\FrameworkBundle\\Tests', 'path' => __DIR__.'/Fixtures/CustomPathBundle']], ]); @@ -1337,7 +1335,7 @@ public function testValidationNoStaticMethod() $this->assertSame('addXmlMappings', $calls[3][0]); $i = 3; if ($annotations) { - $this->assertSame('enableAnnotationMapping', $calls[++$i][0]); + $this->assertSame('enableAttributeMapping', $calls[++$i][0]); } $this->assertSame('setMappingCache', $calls[++$i][0]); $this->assertEquals([new Reference('validator.mapping.cache.adapter')], $calls[$i][1]); @@ -1550,6 +1548,12 @@ public function testSerializerCacheActivated() public function testSerializerCacheUsedWithoutAnnotationsAndMappingFiles() { $container = $this->createContainerFromFile('serializer_mapping_without_annotations', ['kernel.debug' => true, 'kernel.container_class' => __CLASS__]); + $this->assertFalse($container->hasDefinition('serializer.mapping.cache_class_metadata_factory')); + } + + public function testSerializerCacheUsedWithoutAnnotationsAndMappingFilesNoDebug() + { + $container = $this->createContainerFromFile('serializer_mapping_without_annotations', ['kernel.debug' => false, 'kernel.container_class' => __CLASS__]); $this->assertTrue($container->hasDefinition('serializer.mapping.cache_class_metadata_factory')); } @@ -2048,7 +2052,6 @@ public function testRegisterParameterCollectingBehaviorDescribingTags() $this->assertTrue($container->hasParameter('container.behavior_describing_tags')); $this->assertEquals([ - 'annotations.cached_reader', 'container.do_not_inline', 'container.service_locator', 'container.service_subscriber', @@ -2321,7 +2324,6 @@ protected function createContainerFromFile(string $file, array $data = [], bool } $container->getCompilerPassConfig()->setBeforeOptimizationPasses([new LoggerPass()]); $container->getCompilerPassConfig()->setBeforeRemovingPasses([new AddConstraintValidatorsPass(), new TranslatorPass()]); - $container->getCompilerPassConfig()->setAfterRemovingPasses([new AddAnnotationsCachedReaderPass()]); if (!$compile) { return $container; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/DependencyInjection/AnnotationReaderPass.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/DependencyInjection/AnnotationReaderPass.php deleted file mode 100644 index 9e61c5ae76f64..0000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/DependencyInjection/AnnotationReaderPass.php +++ /dev/null @@ -1,24 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\DependencyInjection; - -use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; - -class AnnotationReaderPass implements CompilerPassInterface -{ - public function process(ContainerBuilder $container): void - { - // simulate using "annotation_reader" in a compiler pass - $container->get('test.annotation_reader'); - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/TestBundle.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/TestBundle.php index 7dbbb096b8e14..d0c6588b00568 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/TestBundle.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/TestBundle.php @@ -11,7 +11,6 @@ namespace Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle; -use Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\DependencyInjection\AnnotationReaderPass; use Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\DependencyInjection\Config\CustomConfig; use Symfony\Component\DependencyInjection\Compiler\CheckTypeDeclarationsPass; use Symfony\Component\DependencyInjection\Compiler\PassConfig; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml index bfd6e1b5a94f2..1eaee513c899b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml @@ -4,7 +4,7 @@ framework: handle_all_throwables: true secret: test router: { resource: "%kernel.project_dir%/%kernel.test_case%/routing.yml", utf8: true } - validation: { enabled: true, enable_annotations: true, email_validation_mode: html5 } + validation: { enabled: true, enable_attributes: true, email_validation_mode: html5 } csrf_protection: true form: enabled: true diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index bac62baab8390..818f4aa5075ce 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -70,13 +70,14 @@ "symfony/uid": "^6.4|^7.0", "symfony/web-link": "^6.4|^7.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "twig/twig": "^2.10|^3.0" + "twig/twig": "^3.0.4" }, "conflict": { "doctrine/persistence": "<1.3", "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", "symfony/asset": "<6.4", + "symfony/asset-mapper": "<6.4", "symfony/clock": "<6.4", "symfony/console": "<6.4", "symfony/dotenv": "<6.4", diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php index 222bc0e43ffa6..dee05317ade22 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php @@ -16,9 +16,6 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; /** - * AbstractFactory is the base class for all classes inheriting from - * AbstractAuthenticationListener. - * * @author Fabien Potencier * @author Lukas Kahwe Smith * @author Johannes M. Schmitt diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php index b696f9e02d91c..b62720bfd80d8 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php @@ -76,6 +76,7 @@ public function createAuthenticator(ContainerBuilder $container, string $firewal $container->register($config['limiter'] = 'security.login_throttling.'.$firewallName.'.limiter', DefaultLoginRateLimiter::class) ->addArgument(new Reference('limiter.'.$globalId)) ->addArgument(new Reference('limiter.'.$localId)) + ->addArgument('%kernel.secret%') ; } diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig b/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig index 48e6c95998c7a..4dd0b021fe9d2 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig +++ b/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig @@ -28,6 +28,25 @@ border: 0; padding: 0 0 8px 0; } + + #collector-content .authenticators .badge { + color: var(--white); + display: inline-block; + text-align: center; + } + #collector-content .authenticators .badge.badge-resolved { + background-color: var(--green-500); + } + #collector-content .authenticators .badge.badge-not_resolved { + background-color: var(--yellow-500); + } + + #collector-content .authenticators svg[data-icon-name="icon-tabler-check"] { + color: var(--green-500); + } + #collector-content .authenticators svg[data-icon-name="icon-tabler-x"] { + color: var(--red-500); + } {% endblock %} @@ -316,13 +335,15 @@

Authenticators

{% if collector.authenticators|default([]) is not empty %} - +
+ + @@ -340,8 +361,18 @@ + + {% if loop.last %} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php index 3deb91402165e..6cc2b1f0fb150 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php @@ -333,6 +333,18 @@ public function testSelfContainedTokens() $this->assertSame(['message' => 'Welcome @dunglas!'], json_decode($response->getContent(), true)); } + public function testCustomUserLoader() + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_custom_user_loader.yml']); + $client->catchExceptions(false); + $client->request('GET', '/foo', [], [], ['HTTP_AUTHORIZATION' => 'Bearer SELF_CONTAINED_ACCESS_TOKEN']); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['message' => 'Welcome @dunglas!'], json_decode($response->getContent(), true)); + } + /** * @requires extension openssl */ diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php index a704bb5654d2e..38d6838899ad1 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php @@ -207,11 +207,6 @@ public function getSalt(): string return ''; } - public function getUsername(): string - { - return $this->username; - } - public function getUserIdentifier(): string { return $this->username; diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_custom_user_loader.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_custom_user_loader.yml new file mode 100644 index 0000000000000..2027656b4d83c --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_custom_user_loader.yml @@ -0,0 +1,32 @@ +imports: + - { resource: ./../config/framework.yml } + +framework: + http_method_override: false + serializer: ~ + +security: + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_MISSING] } + + firewalls: + main: + pattern: ^/ + stateless: true + access_token: + token_handler: access_token.access_token_handler + token_extractors: 'header' + realm: 'My API' + + access_control: + - { path: ^/foo, roles: ROLE_USER } + +services: + access_token.access_token_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Handler\AccessTokenHandler diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml index e7fbaa4adde07..9d6b4caee1707 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml @@ -4,7 +4,7 @@ framework: handle_all_throwables: true secret: test router: { resource: "%kernel.project_dir%/%kernel.test_case%/routing.yml", utf8: true } - validation: { enabled: true, enable_annotations: true, email_validation_mode: html5 } + validation: { enabled: true, enable_attributes: true, email_validation_mode: html5 } csrf_protection: true form: enabled: true diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/config/framework.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/config/framework.yml index 7ef9642b7daab..c197fcaa4c25e 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/config/framework.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/config/framework.yml @@ -4,7 +4,7 @@ framework: handle_all_throwables: true secret: test router: { resource: "%kernel.project_dir%/%kernel.test_case%/routing.yml", utf8: true } - validation: { enabled: true, enable_annotations: true, email_validation_mode: html5 } + validation: { enabled: true, enable_attributes: true, email_validation_mode: html5 } assets: ~ csrf_protection: true form: diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json index 05101f99d6816..cd77b9d043a30 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -49,7 +49,7 @@ "symfony/twig-bridge": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/yaml": "^6.4|^7.0", - "twig/twig": "^2.13|^3.0.4", + "twig/twig": "^3.0.4", "web-token/jwt-checker": "^3.1", "web-token/jwt-signature-algorithm-hmac": "^3.1", "web-token/jwt-signature-algorithm-ecdsa": "^3.1", diff --git a/src/Symfony/Bundle/TwigBundle/CHANGELOG.md b/src/Symfony/Bundle/TwigBundle/CHANGELOG.md index 8c2e0cd85332c..e1603edc06e03 100644 --- a/src/Symfony/Bundle/TwigBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/TwigBundle/CHANGELOG.md @@ -8,6 +8,7 @@ CHANGELOG * Remove option `twig.autoescape`; create a class that implements your escaping strategy (check `FileExtensionEscapingStrategy::guess()` for inspiration) and reference it using the `twig.autoescape_service` option instead + * Drop support for Twig 2 6.4 --- diff --git a/src/Symfony/Bundle/TwigBundle/composer.json b/src/Symfony/Bundle/TwigBundle/composer.json index e572cdbb51071..88c1dd5b85415 100644 --- a/src/Symfony/Bundle/TwigBundle/composer.json +++ b/src/Symfony/Bundle/TwigBundle/composer.json @@ -23,7 +23,7 @@ "symfony/twig-bridge": "^6.4|^7.0", "symfony/http-foundation": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", - "twig/twig": "^2.13|^3.0.4" + "twig/twig": "^3.0.4" }, "require-dev": { "symfony/asset": "^6.4|^7.0", diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/ICONS_LICENSE.txt b/src/Symfony/Bundle/WebProfilerBundle/Resources/ICONS_LICENSE.txt deleted file mode 100644 index 2e20272676e40..0000000000000 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/ICONS_LICENSE.txt +++ /dev/null @@ -1,5 +0,0 @@ -Icons License -============= - -Icons created by Sensio (http://www.sensio.com/) are shared under a Creative -Commons Attribution license (http://creativecommons.org/licenses/by-sa/3.0/). \ No newline at end of file diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/form.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/form.html.twig index d2616f2bf0630..cb6b32a5c981c 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/form.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/form.html.twig @@ -97,14 +97,8 @@ .toggle-icon { display: inline-block; - background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' data-icon-name='icon-tabler-square-plus' width='24' height='24' viewBox='0 0 24 24' stroke-width='2px' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Crect x='4' y='4' width='16' height='16' rx='2'%3E%3C/rect%3E%3Cline x1='9' y1='12' x2='15' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='9' x2='12' y2='15'%3E%3C/line%3E%3C/svg%3E") no-repeat; - background-size: 18px 18px; } .closed .toggle-icon, .closed.toggle-icon { - background-position: bottom left; - } - .toggle-icon.empty { - background-image: none; } .tree .tree-inner { @@ -118,11 +112,19 @@ width: 16px; height: 16px; margin-left: -18px; + display: inline-grid; + place-content: center; + background: none; + border: none; + transition: transform 200ms; } - .tree .toggle-icon { - width: 18px; - height: 18px; - vertical-align: bottom; + .tree .toggle-button.closed svg { + transform: rotate(-90deg); + } + .tree .toggle-button svg { + transform: rotate(0deg); + width: 16px; + height: 16px; } .tree .toggle-icon.empty { width: 5px; @@ -406,7 +408,9 @@ {% endif %} {% if data.children is not empty %} - + {% else %}
{% endif %} @@ -496,12 +500,6 @@ {% macro render_form_errors(data) %} {% if data.errors is defined and data.errors|length > 0 %}
-

- - Errors - -

-
Authenticator SupportsAuthenticated Duration PassportBadges
{{ profiler_dump(authenticator.stub) }} {{ source('@WebProfiler/Icon/' ~ (authenticator.supports ? 'yes' : 'no') ~ '.svg') }}{{ authenticator.authenticated is not null ? source('@WebProfiler/Icon/' ~ (authenticator.authenticated ? 'yes' : 'no') ~ '.svg') : '' }} {{ '%0.2f'|format(authenticator.duration * 1000) }} ms {{ authenticator.passport ? profiler_dump(authenticator.passport) : '(none)' }} + {% for badge in authenticator.badges ?? [] %} + + {{ badge.stub|abbr_class }} + + {% else %} + (none) + {% endfor %} +
diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/messenger.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/messenger.html.twig index ddc6964e3d89d..d6630e6780eba 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/messenger.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/messenger.html.twig @@ -8,8 +8,9 @@ .message-item tbody tr td:first-child { width: 170px; } .message-item .label { float: right; padding: 1px 5px; opacity: .75; margin-left: 5px; } - .message-item .toggle-button { position: absolute; right: 6px; top: 6px; opacity: .5; pointer-events: none } + .message-item .toggle-button { position: absolute; right: 6px; top: 6px; opacity: .5; pointer-events: none; color: inherit; } .message-item .icon svg { height: 24px; width: 24px; } + .message-item .icon-close svg { transform: rotate(180deg); } .message-item .sf-toggle-off .icon-close, .sf-toggle-on .icon-open { display: none; } .message-item .sf-toggle-off .icon-open, .sf-toggle-on .icon-close { display: block; } @@ -131,10 +132,10 @@ {% if dispatchCall.exception is defined %} exception {% endif %} - - {{ source('@WebProfiler/images/icon-minus-square.svg') }} - {{ source('@WebProfiler/images/icon-plus-square.svg') }} - + diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/chevron-down.svg b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/chevron-down.svg new file mode 100644 index 0000000000000..359e3da8c7035 --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/chevron-down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/images/icon-minus-square.svg b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/images/icon-minus-square.svg deleted file mode 100644 index 471c2741c7fd7..0000000000000 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/images/icon-minus-square.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/images/icon-plus-square.svg b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/images/icon-plus-square.svg deleted file mode 100644 index 2f5c3b3583076..0000000000000 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/images/icon-plus-square.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/Symfony/Bundle/WebProfilerBundle/composer.json b/src/Symfony/Bundle/WebProfilerBundle/composer.json index 14cf064567b76..2de2677c5b0c3 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/composer.json +++ b/src/Symfony/Bundle/WebProfilerBundle/composer.json @@ -22,7 +22,7 @@ "symfony/http-kernel": "^6.4|^7.0", "symfony/routing": "^6.4|^7.0", "symfony/twig-bundle": "^6.4|^7.0", - "twig/twig": "^2.13|^3.0.4" + "twig/twig": "^3.0.4" }, "require-dev": { "symfony/browser-kit": "^6.4|^7.0", diff --git a/src/Symfony/Component/AssetMapper/CHANGELOG.md b/src/Symfony/Component/AssetMapper/CHANGELOG.md index 140d728dbfa51..ae70d59485362 100644 --- a/src/Symfony/Component/AssetMapper/CHANGELOG.md +++ b/src/Symfony/Component/AssetMapper/CHANGELOG.md @@ -5,6 +5,8 @@ CHANGELOG --- * Mark the component as non experimental + * Add a `importmap:install` command to download all missing downloaded packages + * Allow specifying packages to update for the `importmap:update` command 6.3 --- diff --git a/src/Symfony/Component/AssetMapper/Command/ImportMapInstallCommand.php b/src/Symfony/Component/AssetMapper/Command/ImportMapInstallCommand.php new file mode 100644 index 0000000000000..6924deddc55ca --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Command/ImportMapInstallCommand.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Command; + +use Symfony\Component\AssetMapper\ImportMap\ImportMapManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Downloads all assets that should be downloaded. + * + * @author Jonathan Scheiber + */ +#[AsCommand(name: 'importmap:install', description: 'Downloads all assets that should be downloaded.')] +final class ImportMapInstallCommand extends Command +{ + public function __construct( + private readonly ImportMapManager $importMapManager, + ) { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $downloadedPackages = $this->importMapManager->downloadMissingPackages(); + $io->success(sprintf('Downloaded %d assets.', \count($downloadedPackages))); + + return Command::SUCCESS; + } +} diff --git a/src/Symfony/Component/AssetMapper/Command/ImportMapUpdateCommand.php b/src/Symfony/Component/AssetMapper/Command/ImportMapUpdateCommand.php index eed445e45056c..f4ad4f2bdba59 100644 --- a/src/Symfony/Component/AssetMapper/Command/ImportMapUpdateCommand.php +++ b/src/Symfony/Component/AssetMapper/Command/ImportMapUpdateCommand.php @@ -11,9 +11,11 @@ namespace Symfony\Component\AssetMapper\Command; +use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; use Symfony\Component\AssetMapper\ImportMap\ImportMapManager; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -21,7 +23,7 @@ /** * @author Kévin Dunglas */ -#[AsCommand(name: 'importmap:update', description: 'Updates all JavaScript packages to their latest versions')] +#[AsCommand(name: 'importmap:update', description: 'Updates JavaScript packages to their latest versions')] final class ImportMapUpdateCommand extends Command { public function __construct( @@ -33,21 +35,37 @@ public function __construct( protected function configure(): void { $this + ->addArgument('packages', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'List of packages\' names') ->setHelp(<<<'EOT' The %command.name% command will update all from the 3rd part packages in importmap.php to their latest version, including downloaded packages. php %command.full_name% + +Or specific packages only: + + php %command.full_name% EOT - ); + ) + ; } protected function execute(InputInterface $input, OutputInterface $output): int { + $packages = $input->getArgument('packages'); + $io = new SymfonyStyle($input, $output); - $this->importMapManager->update(); + $updatedPackages = $this->importMapManager->update($packages); - $io->success('Updated all packages in importmap.php.'); + if (0 < \count($packages)) { + $io->success(sprintf( + 'Updated %s package%s in importmap.php.', + implode(', ', array_map(static fn (ImportMapEntry $entry): string => $entry->importName, $updatedPackages)), + 1 < \count($updatedPackages) ? 's' : '', + )); + } else { + $io->success('Updated all packages in importmap.php.'); + } return Command::SUCCESS; } diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php index 699dcdae2cade..0a46133e52ee0 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php @@ -15,7 +15,9 @@ use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\AssetMapper\ImportMap\Resolver\PackageResolverInterface; use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface; +use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\VarExporter\VarExporter; +use Symfony\Contracts\HttpClient\HttpClientInterface; /** * @author Kévin Dunglas @@ -54,6 +56,7 @@ class ImportMapManager private array $importMapEntries; private array $modulesToPreload; private string $json; + private readonly HttpClientInterface $httpClient; public function __construct( private readonly AssetMapperInterface $assetMapper, @@ -61,7 +64,9 @@ public function __construct( private readonly string $importMapConfigPath, private readonly string $vendorDir, private readonly PackageResolverInterface $resolver, + HttpClientInterface $httpClient = null, ) { + $this->httpClient = $httpClient ?? HttpClient::create(); } public function getModulesToPreload(): array @@ -87,7 +92,7 @@ public function getImportMapJson(): string */ public function require(array $packages): array { - return $this->updateImportMapConfig(false, $packages, []); + return $this->updateImportMapConfig(false, $packages, [], []); } /** @@ -97,15 +102,43 @@ public function require(array $packages): array */ public function remove(array $packages): void { - $this->updateImportMapConfig(false, [], $packages); + $this->updateImportMapConfig(false, [], $packages, []); } /** - * Updates all existing packages to the latest version. + * Updates either all existing packages or the specified ones to the latest version. + * + * @return ImportMapEntry[] + */ + public function update(array $packages = []): array + { + return $this->updateImportMapConfig(true, [], [], $packages); + } + + /** + * Downloads all missing downloaded packages. + * + * @return string[] The downloaded packages */ - public function update(): array + public function downloadMissingPackages(): array { - return $this->updateImportMapConfig(true, [], []); + $entries = $this->loadImportMapEntries(); + $downloadedPackages = []; + + foreach ($entries as $entry) { + if (!$entry->isDownloaded || $this->assetMapper->getAsset($entry->path)) { + continue; + } + + $this->downloadPackage( + $entry->importName, + $this->httpClient->request('GET', $entry->url)->getContent(), + ); + + $downloadedPackages[] = $entry->importName; + } + + return $downloadedPackages; } /** @@ -159,7 +192,7 @@ private function buildImportMapJson(): void * * @return ImportMapEntry[] */ - private function updateImportMapConfig(bool $update, array $packagesToRequire, array $packagesToRemove): array + private function updateImportMapConfig(bool $update, array $packagesToRequire, array $packagesToRemove, array $packagesToUpdate): array { $currentEntries = $this->loadImportMapEntries(); @@ -174,7 +207,7 @@ private function updateImportMapConfig(bool $update, array $packagesToRequire, a if ($update) { foreach ($currentEntries as $importName => $entry) { - if (null === $entry->url) { + if (null === $entry->url || (0 !== \count($packagesToUpdate) && !\in_array($importName, $packagesToUpdate, true))) { continue; } @@ -380,6 +413,10 @@ private function convertEntriesToImports(array $entries): array if (null !== $entryOptions->path) { if (!$asset = $this->assetMapper->getAsset($entryOptions->path)) { + if ($entryOptions->isDownloaded) { + throw new \InvalidArgumentException(sprintf('The "%s" downloaded asset is missing. Run "php bin/console importmap:install".', $entryOptions->path)); + } + throw new \InvalidArgumentException(sprintf('The asset "%s" mentioned in "%s" cannot be found in any asset map paths.', $entryOptions->path, basename($this->importMapConfigPath))); } $path = $asset->publicPath; diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php index c947d334909e4..013849b6b7e81 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php @@ -26,12 +26,15 @@ use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolver; use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; class ImportMapManagerTest extends TestCase { private Filesystem $filesystem; private AssetMapperInterface $assetMapper; private PackageResolverInterface&MockObject $packageResolver; + private HttpClientInterface&MockObject $httpClient; protected function setUp(): void { @@ -373,6 +376,103 @@ public function testUpdate() $this->assertSame('contents of cowsay.js', $actualContents); } + public function testUpdateWithSpecificPackages() + { + $rootDir = __DIR__.'/../fixtures/importmaps_for_writing'; + $manager = $this->createImportMapManager(['assets' => ''], $rootDir); + + $map = [ + 'lodash' => [ + 'url' => 'https://ga.jspm.io/npm:lodash@1.2.3/lodash.js', + ], + 'cowsay' => [ + 'url' => 'https://ga.jspm.io/npm:cowsay@4.5.6/cowsay.umd.js', + 'downloaded_to' => 'vendor/cowsay.js', + ], + 'bootstrap' => [ + 'url' => 'https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.esm.js', + 'preload' => true, + ], + 'app' => [ + 'path' => 'app.js', + ], + ]; + $mapString = var_export($map, true); + file_put_contents($rootDir.'/importmap.php', "filesystem->mkdir($rootDir.'/assets/vendor'); + file_put_contents($rootDir.'/assets/vendor/cowsay.js', 'cowsay.js original contents'); + file_put_contents($rootDir.'/assets/app.js', 'app.js contents'); + + $this->packageResolver->expects($this->once()) + ->method('resolvePackages') + ->willReturn([ + self::resolvedPackage('cowsay', 'https://ga.jspm.io/npm:cowsay@4.5.9/cowsay.umd.js', download: true, content: 'updated contents of cowsay.js'), + ]) + ; + + $manager->update(['cowsay']); + $actualImportMap = require $rootDir.'/importmap.php'; + $expectedImportMap = [ + 'lodash' => [ + 'url' => 'https://ga.jspm.io/npm:lodash@1.2.3/lodash.js', + ], + 'cowsay' => [ + 'url' => 'https://ga.jspm.io/npm:cowsay@4.5.9/cowsay.umd.js', + 'downloaded_to' => 'vendor/cowsay.js', + ], + 'bootstrap' => [ + 'url' => 'https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.esm.js', + 'preload' => true, + ], + 'app' => [ + 'path' => 'app.js', + ], + ]; + $this->assertEquals($expectedImportMap, $actualImportMap); + $this->assertFileExists($rootDir.'/assets/vendor/cowsay.js'); + $actualContents = file_get_contents($rootDir.'/assets/vendor/cowsay.js'); + $this->assertSame('updated contents of cowsay.js', $actualContents); + } + + public function testDownloadMissingPackages() + { + $rootDir = __DIR__.'/../fixtures/download'; + $manager = $this->createImportMapManager(['assets' => ''], $rootDir); + + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->once()) + ->method('getContent') + ->willReturn('contents of stimulus.js'); + + $this->httpClient->expects($this->once()) + ->method('request') + ->willReturn($response); + + $downloadedPackages = $manager->downloadMissingPackages(); + $actualImportMap = require $rootDir.'/importmap.php'; + $expectedImportMap = [ + '@hotwired/stimulus' => [ + 'downloaded_to' => 'vendor/@hotwired/stimulus.js', + 'url' => 'https://cdn.jsdelivr.net/npm/stimulus@3.2.1/+esm', + ], + 'lodash' => [ + 'downloaded_to' => 'vendor/lodash.js', + 'url' => 'https://ga.jspm.io/npm:lodash@4.17.21/lodash.js', + ], + ]; + $this->assertEquals($expectedImportMap, $actualImportMap); + + $expectedDownloadedFiles = [ + 'assets/vendor/@hotwired/stimulus.js' => 'contents of stimulus.js', + ]; + foreach ($expectedDownloadedFiles as $file => $expectedContents) { + $this->assertFileExists($rootDir.'/'.$file); + $actualContents = file_get_contents($rootDir.'/'.$file); + $this->assertSame($expectedContents, $actualContents); + unlink($rootDir.'/'.$file); + } + } + /** * @dataProvider getPackageNameTests */ @@ -469,6 +569,7 @@ private function createImportMapManager(array $dirs, string $rootDir, string $pu $mapper = $this->createAssetMapper($pathResolver, $dirs, $rootDir); $this->packageResolver = $this->createMock(PackageResolverInterface::class); + $this->httpClient = $this->createMock(HttpClientInterface::class); return new ImportMapManager( $mapper, @@ -476,6 +577,7 @@ private function createImportMapManager(array $dirs, string $rootDir, string $pu $rootDir.'/importmap.php', $rootDir.'/assets/vendor', $this->packageResolver, + $this->httpClient, ); } diff --git a/src/Symfony/Component/AssetMapper/Tests/fixtures/download/assets/vendor/lodash.js b/src/Symfony/Component/AssetMapper/Tests/fixtures/download/assets/vendor/lodash.js new file mode 100644 index 0000000000000..ac1d7f73afb58 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Tests/fixtures/download/assets/vendor/lodash.js @@ -0,0 +1 @@ +console.log('fake downloaded lodash.js'); diff --git a/src/Symfony/Component/AssetMapper/Tests/fixtures/download/importmap.php b/src/Symfony/Component/AssetMapper/Tests/fixtures/download/importmap.php new file mode 100644 index 0000000000000..30bb5a9469f59 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Tests/fixtures/download/importmap.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return [ + '@hotwired/stimulus' => [ + 'downloaded_to' => 'vendor/@hotwired/stimulus.js', + 'url' => 'https://cdn.jsdelivr.net/npm/stimulus@3.2.1/+esm', + ], + 'lodash' => [ + 'downloaded_to' => 'vendor/lodash.js', + 'url' => 'https://ga.jspm.io/npm:lodash@4.17.21/lodash.js', + ], +]; diff --git a/src/Symfony/Component/Cache/Traits/RedisTrait.php b/src/Symfony/Component/Cache/Traits/RedisTrait.php index 04f81d75cf586..bf45048590059 100644 --- a/src/Symfony/Component/Cache/Traits/RedisTrait.php +++ b/src/Symfony/Component/Cache/Traits/RedisTrait.php @@ -227,8 +227,11 @@ public static function createConnection(#[\SensitiveParameter] string $dsn, arra } $sentinel = new $sentinelClass($host, $port, $params['timeout'], (string) $params['persistent_id'], $params['retry_interval'], $params['read_timeout'], ...$extra); - if ($address = $sentinel->getMasterAddrByName($params['redis_sentinel'])) { - [$host, $port] = $address; + try { + if ($address = $sentinel->getMasterAddrByName($params['redis_sentinel'])) { + [$host, $port] = $address; + } + } catch (\RedisException $e) { } } while (++$hostIndex < \count($hosts) && !$address); diff --git a/src/Symfony/Component/Cache/Traits/RelayProxy.php b/src/Symfony/Component/Cache/Traits/RelayProxy.php index 7aa1bbcc5bfc7..4c3ed22c080c3 100644 --- a/src/Symfony/Component/Cache/Traits/RelayProxy.php +++ b/src/Symfony/Component/Cache/Traits/RelayProxy.php @@ -456,6 +456,11 @@ public function bitcount($key, $start = 0, $end = -1, $by_bit = false): \Relay\R return ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->bitcount(...\func_get_args()); } + public function bitfield($key, ...$args): \Relay\Relay|array|false + { + return ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->bitfield(...\func_get_args()); + } + public function config($operation, $key = null, $value = null): \Relay\Relay|array|bool { return ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->config(...\func_get_args()); diff --git a/src/Symfony/Component/Console/README.md b/src/Symfony/Component/Console/README.md index bfd4881092b5f..92f70e714c550 100644 --- a/src/Symfony/Component/Console/README.md +++ b/src/Symfony/Component/Console/README.md @@ -7,14 +7,7 @@ interfaces. Sponsor ------- -The Console component for Symfony 6.3 is [backed][1] by [Les-Tilleuls.coop][2]. - -Les-Tilleuls.coop is a team of 70+ Symfony experts who can help you design, develop and -fix your projects. They provide a wide range of professional services including development, -consulting, coaching, training and audits. They also are highly skilled in JS, Go and DevOps. -They are a worker cooperative! - -Help Symfony by [sponsoring][3] its development! +Help Symfony by [sponsoring][1] its development! Resources --------- @@ -31,6 +24,4 @@ Credits `Resources/bin/hiddeninput.exe` is a third party binary provided within this component. Find sources and license at https://github.com/Seldaek/hidden-input. -[1]: https://symfony.com/backers -[2]: https://les-tilleuls.coop -[3]: https://symfony.com/sponsor +[1]: https://symfony.com/sponsor diff --git a/src/Symfony/Component/Console/Tests/Helper/ProcessHelperTest.php b/src/Symfony/Component/Console/Tests/Helper/ProcessHelperTest.php index d743944eb8e37..1fd88987baabe 100644 --- a/src/Symfony/Component/Console/Tests/Helper/ProcessHelperTest.php +++ b/src/Symfony/Component/Console/Tests/Helper/ProcessHelperTest.php @@ -23,10 +23,10 @@ class ProcessHelperTest extends TestCase /** * @dataProvider provideCommandsAndOutput */ - public function testVariousProcessRuns($expected, $cmd, $verbosity, $error) + public function testVariousProcessRuns(string $expected, Process|string|array $cmd, int $verbosity, ?string $error) { if (\is_string($cmd)) { - $cmd = method_exists(Process::class, 'fromShellCommandline') ? Process::fromShellCommandline($cmd) : new Process($cmd); + $cmd = Process::fromShellCommandline($cmd); } $helper = new ProcessHelper(); @@ -49,7 +49,7 @@ public function testPassedCallbackIsExecuted() $this->assertTrue($executed); } - public static function provideCommandsAndOutput() + public static function provideCommandsAndOutput(): array { $successOutputVerbose = <<<'EOT' RUN php -r "echo 42;" @@ -99,7 +99,6 @@ public static function provideCommandsAndOutput() $args = new Process(['php', '-r', 'echo 42;']); $args = $args->getCommandLine(); $successOutputProcessDebug = str_replace("'php' '-r' 'echo 42;'", $args, $successOutputProcessDebug); - $fromShellCommandline = method_exists(Process::class, 'fromShellCommandline') ? [Process::class, 'fromShellCommandline'] : fn ($cmd) => new Process($cmd); return [ ['', 'php -r "echo 42;"', StreamOutput::VERBOSITY_VERBOSE, null], @@ -113,18 +112,18 @@ public static function provideCommandsAndOutput() [$syntaxErrorOutputVerbose.$errorMessage.\PHP_EOL, 'php -r "fwrite(STDERR, \'error message\');usleep(50000);fwrite(STDOUT, \'out message\');exit(252);"', StreamOutput::VERBOSITY_VERY_VERBOSE, $errorMessage], [$syntaxErrorOutputDebug.$errorMessage.\PHP_EOL, 'php -r "fwrite(STDERR, \'error message\');usleep(500000);fwrite(STDOUT, \'out message\');exit(252);"', StreamOutput::VERBOSITY_DEBUG, $errorMessage], [$successOutputProcessDebug, ['php', '-r', 'echo 42;'], StreamOutput::VERBOSITY_DEBUG, null], - [$successOutputDebug, $fromShellCommandline('php -r "echo 42;"'), StreamOutput::VERBOSITY_DEBUG, null], + [$successOutputDebug, Process::fromShellCommandline('php -r "echo 42;"'), StreamOutput::VERBOSITY_DEBUG, null], [$successOutputProcessDebug, [new Process(['php', '-r', 'echo 42;'])], StreamOutput::VERBOSITY_DEBUG, null], - [$successOutputPhp, [$fromShellCommandline('php -r '.$PHP), 'PHP' => 'echo 42;'], StreamOutput::VERBOSITY_DEBUG, null], + [$successOutputPhp, [Process::fromShellCommandline('php -r '.$PHP), 'PHP' => 'echo 42;'], StreamOutput::VERBOSITY_DEBUG, null], ]; } - private function getOutputStream($verbosity) + private function getOutputStream($verbosity): StreamOutput { return new StreamOutput(fopen('php://memory', 'r+', false), $verbosity, false); } - private function getOutput(StreamOutput $output) + private function getOutput(StreamOutput $output): string { rewind($output->getStream()); diff --git a/src/Symfony/Component/DependencyInjection/Attribute/AutowireLocator.php b/src/Symfony/Component/DependencyInjection/Attribute/AutowireLocator.php new file mode 100644 index 0000000000000..cae2d52a8b1cf --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Attribute/AutowireLocator.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Attribute; + +use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Reference; + +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class AutowireLocator extends Autowire +{ + public function __construct(string ...$serviceIds) + { + $values = []; + + foreach ($serviceIds as $key => $serviceId) { + if ($nullable = str_starts_with($serviceId, '?')) { + $serviceId = substr($serviceId, 1); + } + + if (is_numeric($key)) { + $key = $serviceId; + } + + $values[$key] = new Reference( + $serviceId, + $nullable ? ContainerInterface::IGNORE_ON_INVALID_REFERENCE : ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, + ); + } + + parent::__construct(new ServiceLocatorArgument($values)); + } +} diff --git a/src/Symfony/Component/DependencyInjection/CHANGELOG.md b/src/Symfony/Component/DependencyInjection/CHANGELOG.md index 1c526281d49f8..6b147d14f5e34 100644 --- a/src/Symfony/Component/DependencyInjection/CHANGELOG.md +++ b/src/Symfony/Component/DependencyInjection/CHANGELOG.md @@ -21,6 +21,7 @@ CHANGELOG * Allow using `#[Target]` with no arguments to state that a parameter must match a named autowiring alias * Deprecate `ContainerAwareInterface` and `ContainerAwareTrait`, use dependency injection instead * Add `defined` env var processor that returns `true` for defined and neither null nor empty env vars + * Add `#[AutowireLocator]` attribute 6.3 --- diff --git a/src/Symfony/Component/DependencyInjection/Tests/Attribute/AutowireLocatorTest.php b/src/Symfony/Component/DependencyInjection/Tests/Attribute/AutowireLocatorTest.php new file mode 100644 index 0000000000000..50b54ac4a93a9 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Attribute/AutowireLocatorTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Attribute; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; +use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Reference; + +class AutowireLocatorTest extends TestCase +{ + public function testSimpleLocator() + { + $locator = new AutowireLocator('foo', 'bar'); + + $this->assertEquals( + new ServiceLocatorArgument(['foo' => new Reference('foo'), 'bar' => new Reference('bar')]), + $locator->value, + ); + } + + public function testComplexLocator() + { + $locator = new AutowireLocator( + '?qux', + foo: 'bar', + bar: '?baz', + ); + + $this->assertEquals( + new ServiceLocatorArgument([ + 'qux' => new Reference('qux', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), + 'foo' => new Reference('bar'), + 'bar' => new Reference('baz', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), + ]), + $locator->value, + ); + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/IntegrationTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/IntegrationTest.php index 5594e6ba73009..4f318b9893ae1 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Compiler/IntegrationTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/IntegrationTest.php @@ -32,23 +32,24 @@ use Symfony\Component\DependencyInjection\Tests\Fixtures\AutoconfiguredInterface2; use Symfony\Component\DependencyInjection\Tests\Fixtures\AutoconfiguredService1; use Symfony\Component\DependencyInjection\Tests\Fixtures\AutoconfiguredService2; +use Symfony\Component\DependencyInjection\Tests\Fixtures\AutowireLocatorConsumer; use Symfony\Component\DependencyInjection\Tests\Fixtures\BarTagClass; use Symfony\Component\DependencyInjection\Tests\Fixtures\FooBarTaggedClass; use Symfony\Component\DependencyInjection\Tests\Fixtures\FooBarTaggedForDefaultPriorityClass; use Symfony\Component\DependencyInjection\Tests\Fixtures\FooTagClass; -use Symfony\Component\DependencyInjection\Tests\Fixtures\IteratorConsumer; -use Symfony\Component\DependencyInjection\Tests\Fixtures\IteratorConsumerWithDefaultIndexMethod; -use Symfony\Component\DependencyInjection\Tests\Fixtures\IteratorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod; -use Symfony\Component\DependencyInjection\Tests\Fixtures\IteratorConsumerWithDefaultPriorityMethod; -use Symfony\Component\DependencyInjection\Tests\Fixtures\LocatorConsumer; -use Symfony\Component\DependencyInjection\Tests\Fixtures\LocatorConsumerConsumer; -use Symfony\Component\DependencyInjection\Tests\Fixtures\LocatorConsumerFactory; -use Symfony\Component\DependencyInjection\Tests\Fixtures\LocatorConsumerWithDefaultIndexMethod; -use Symfony\Component\DependencyInjection\Tests\Fixtures\LocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod; -use Symfony\Component\DependencyInjection\Tests\Fixtures\LocatorConsumerWithDefaultPriorityMethod; -use Symfony\Component\DependencyInjection\Tests\Fixtures\LocatorConsumerWithoutIndex; use Symfony\Component\DependencyInjection\Tests\Fixtures\StaticMethodTag; use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedConsumerWithExclude; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedIteratorConsumer; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedIteratorConsumerWithDefaultIndexMethod; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedIteratorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedIteratorConsumerWithDefaultPriorityMethod; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedLocatorConsumer; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedLocatorConsumerConsumer; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedLocatorConsumerFactory; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedLocatorConsumerWithDefaultIndexMethod; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedLocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedLocatorConsumerWithDefaultPriorityMethod; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedLocatorConsumerWithoutIndex; use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedService1; use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedService2; use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedService3; @@ -388,6 +389,30 @@ public function testTaggedServiceWithIndexAttributeAndDefaultMethod() $this->assertSame(['bar_tab_class_with_defaultmethod' => $container->get(BarTagClass::class), 'foo' => $container->get(FooTagClass::class)], $param); } + public function testLocatorConfiguredViaAttribute() + { + $container = new ContainerBuilder(); + $container->register(BarTagClass::class) + ->setPublic(true) + ; + $container->register(FooTagClass::class) + ->setPublic(true) + ; + $container->register(AutowireLocatorConsumer::class) + ->setAutowired(true) + ->setPublic(true) + ; + + $container->compile(); + + /** @var AutowireLocatorConsumer $s */ + $s = $container->get(AutowireLocatorConsumer::class); + + self::assertSame($container->get(BarTagClass::class), $s->locator->get(BarTagClass::class)); + self::assertSame($container->get(FooTagClass::class), $s->locator->get('with_key')); + self::assertFalse($s->locator->has('nullable')); + } + public function testTaggedServiceWithIndexAttributeAndDefaultMethodConfiguredViaAttribute() { $container = new ContainerBuilder(); @@ -399,14 +424,14 @@ public function testTaggedServiceWithIndexAttributeAndDefaultMethodConfiguredVia ->setPublic(true) ->addTag('foo_bar', ['foo' => 'foo']) ; - $container->register(IteratorConsumer::class) + $container->register(TaggedIteratorConsumer::class) ->setAutowired(true) ->setPublic(true) ; $container->compile(); - $s = $container->get(IteratorConsumer::class); + $s = $container->get(TaggedIteratorConsumer::class); $param = iterator_to_array($s->getParam()->getIterator()); $this->assertSame(['bar_tab_class_with_defaultmethod' => $container->get(BarTagClass::class), 'foo' => $container->get(FooTagClass::class)], $param); @@ -423,14 +448,14 @@ public function testTaggedIteratorWithDefaultIndexMethodConfiguredViaAttribute() ->setPublic(true) ->addTag('foo_bar') ; - $container->register(IteratorConsumerWithDefaultIndexMethod::class) + $container->register(TaggedIteratorConsumerWithDefaultIndexMethod::class) ->setAutowired(true) ->setPublic(true) ; $container->compile(); - $s = $container->get(IteratorConsumerWithDefaultIndexMethod::class); + $s = $container->get(TaggedIteratorConsumerWithDefaultIndexMethod::class); $param = iterator_to_array($s->getParam()->getIterator()); $this->assertSame(['bar_tag_class' => $container->get(BarTagClass::class), 'foo_tag_class' => $container->get(FooTagClass::class)], $param); @@ -447,14 +472,14 @@ public function testTaggedIteratorWithDefaultPriorityMethodConfiguredViaAttribut ->setPublic(true) ->addTag('foo_bar') ; - $container->register(IteratorConsumerWithDefaultPriorityMethod::class) + $container->register(TaggedIteratorConsumerWithDefaultPriorityMethod::class) ->setAutowired(true) ->setPublic(true) ; $container->compile(); - $s = $container->get(IteratorConsumerWithDefaultPriorityMethod::class); + $s = $container->get(TaggedIteratorConsumerWithDefaultPriorityMethod::class); $param = iterator_to_array($s->getParam()->getIterator()); $this->assertSame([0 => $container->get(FooTagClass::class), 1 => $container->get(BarTagClass::class)], $param); @@ -471,14 +496,14 @@ public function testTaggedIteratorWithDefaultIndexMethodAndWithDefaultPriorityMe ->setPublic(true) ->addTag('foo_bar') ; - $container->register(IteratorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod::class) + $container->register(TaggedIteratorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod::class) ->setAutowired(true) ->setPublic(true) ; $container->compile(); - $s = $container->get(IteratorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod::class); + $s = $container->get(TaggedIteratorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod::class); $param = iterator_to_array($s->getParam()->getIterator()); $this->assertSame(['foo_tag_class' => $container->get(FooTagClass::class), 'bar_tag_class' => $container->get(BarTagClass::class)], $param); @@ -495,15 +520,15 @@ public function testTaggedLocatorConfiguredViaAttribute() ->setPublic(true) ->addTag('foo_bar', ['foo' => 'foo']) ; - $container->register(LocatorConsumer::class) + $container->register(TaggedLocatorConsumer::class) ->setAutowired(true) ->setPublic(true) ; $container->compile(); - /** @var LocatorConsumer $s */ - $s = $container->get(LocatorConsumer::class); + /** @var TaggedLocatorConsumer $s */ + $s = $container->get(TaggedLocatorConsumer::class); $locator = $s->getLocator(); self::assertSame($container->get(BarTagClass::class), $locator->get('bar_tab_class_with_defaultmethod')); @@ -521,15 +546,15 @@ public function testTaggedLocatorConfiguredViaAttributeWithoutIndex() ->setPublic(true) ->addTag('foo_bar') ; - $container->register(LocatorConsumerWithoutIndex::class) + $container->register(TaggedLocatorConsumerWithoutIndex::class) ->setAutowired(true) ->setPublic(true) ; $container->compile(); - /** @var LocatorConsumerWithoutIndex $s */ - $s = $container->get(LocatorConsumerWithoutIndex::class); + /** @var TaggedLocatorConsumerWithoutIndex $s */ + $s = $container->get(TaggedLocatorConsumerWithoutIndex::class); $locator = $s->getLocator(); self::assertSame($container->get(BarTagClass::class), $locator->get(BarTagClass::class)); @@ -547,15 +572,15 @@ public function testTaggedLocatorWithDefaultIndexMethodConfiguredViaAttribute() ->setPublic(true) ->addTag('foo_bar') ; - $container->register(LocatorConsumerWithDefaultIndexMethod::class) + $container->register(TaggedLocatorConsumerWithDefaultIndexMethod::class) ->setAutowired(true) ->setPublic(true) ; $container->compile(); - /** @var LocatorConsumerWithoutIndex $s */ - $s = $container->get(LocatorConsumerWithDefaultIndexMethod::class); + /** @var TaggedLocatorConsumerWithoutIndex $s */ + $s = $container->get(TaggedLocatorConsumerWithDefaultIndexMethod::class); $locator = $s->getLocator(); self::assertSame($container->get(BarTagClass::class), $locator->get('bar_tag_class')); @@ -573,15 +598,15 @@ public function testTaggedLocatorWithDefaultPriorityMethodConfiguredViaAttribute ->setPublic(true) ->addTag('foo_bar') ; - $container->register(LocatorConsumerWithDefaultPriorityMethod::class) + $container->register(TaggedLocatorConsumerWithDefaultPriorityMethod::class) ->setAutowired(true) ->setPublic(true) ; $container->compile(); - /** @var LocatorConsumerWithoutIndex $s */ - $s = $container->get(LocatorConsumerWithDefaultPriorityMethod::class); + /** @var TaggedLocatorConsumerWithoutIndex $s */ + $s = $container->get(TaggedLocatorConsumerWithDefaultPriorityMethod::class); $locator = $s->getLocator(); @@ -602,15 +627,15 @@ public function testTaggedLocatorWithDefaultIndexMethodAndWithDefaultPriorityMet ->setPublic(true) ->addTag('foo_bar') ; - $container->register(LocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod::class) + $container->register(TaggedLocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod::class) ->setAutowired(true) ->setPublic(true) ; $container->compile(); - /** @var LocatorConsumerWithoutIndex $s */ - $s = $container->get(LocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod::class); + /** @var TaggedLocatorConsumerWithoutIndex $s */ + $s = $container->get(TaggedLocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod::class); $locator = $s->getLocator(); @@ -629,18 +654,18 @@ public function testNestedDefinitionWithAutoconfiguredConstructorArgument() ->setPublic(true) ->addTag('foo_bar', ['foo' => 'foo']) ; - $container->register(LocatorConsumerConsumer::class) + $container->register(TaggedLocatorConsumerConsumer::class) ->setPublic(true) ->setArguments([ - (new Definition(LocatorConsumer::class)) + (new Definition(TaggedLocatorConsumer::class)) ->setAutowired(true), ]) ; $container->compile(); - /** @var LocatorConsumerConsumer $s */ - $s = $container->get(LocatorConsumerConsumer::class); + /** @var TaggedLocatorConsumerConsumer $s */ + $s = $container->get(TaggedLocatorConsumerConsumer::class); $locator = $s->getLocatorConsumer()->getLocator(); self::assertSame($container->get(FooTagClass::class), $locator->get('foo')); @@ -653,17 +678,17 @@ public function testFactoryWithAutoconfiguredArgument() ->setPublic(true) ->addTag('foo_bar', ['key' => 'my_service']) ; - $container->register(LocatorConsumerFactory::class); - $container->register(LocatorConsumer::class) + $container->register(TaggedLocatorConsumerFactory::class); + $container->register(TaggedLocatorConsumer::class) ->setPublic(true) ->setAutowired(true) - ->setFactory(new Reference(LocatorConsumerFactory::class)) + ->setFactory(new Reference(TaggedLocatorConsumerFactory::class)) ; $container->compile(); - /** @var LocatorConsumer $s */ - $s = $container->get(LocatorConsumer::class); + /** @var TaggedLocatorConsumer $s */ + $s = $container->get(TaggedLocatorConsumer::class); $locator = $s->getLocator(); self::assertSame($container->get(FooTagClass::class), $locator->get('my_service')); diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutowireLocatorConsumer.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutowireLocatorConsumer.php new file mode 100644 index 0000000000000..ec65075def77c --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutowireLocatorConsumer.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Fixtures; + +use Psr\Container\ContainerInterface; +use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; + +final class AutowireLocatorConsumer +{ + public function __construct( + #[AutowireLocator( + BarTagClass::class, + with_key: FooTagClass::class, + nullable: '?invalid', + )] + public readonly ContainerInterface $locator, + ) { + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/IteratorConsumer.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedIteratorConsumer.php similarity index 94% rename from src/Symfony/Component/DependencyInjection/Tests/Fixtures/IteratorConsumer.php rename to src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedIteratorConsumer.php index 329a14f39331d..1f9c98d8e6b96 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/IteratorConsumer.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedIteratorConsumer.php @@ -13,7 +13,7 @@ use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; -final class IteratorConsumer +final class TaggedIteratorConsumer { public function __construct( #[TaggedIterator('foo_bar', indexAttribute: 'foo')] diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/IteratorConsumerWithDefaultIndexMethod.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedIteratorConsumerWithDefaultIndexMethod.php similarity index 87% rename from src/Symfony/Component/DependencyInjection/Tests/Fixtures/IteratorConsumerWithDefaultIndexMethod.php rename to src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedIteratorConsumerWithDefaultIndexMethod.php index 9344b575eea79..9e5b279e13396 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/IteratorConsumerWithDefaultIndexMethod.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedIteratorConsumerWithDefaultIndexMethod.php @@ -4,7 +4,7 @@ use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; -final class IteratorConsumerWithDefaultIndexMethod +final class TaggedIteratorConsumerWithDefaultIndexMethod { public function __construct( #[TaggedIterator(tag: 'foo_bar', defaultIndexMethod: 'getDefaultFooName')] diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/IteratorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedIteratorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod.php similarity index 83% rename from src/Symfony/Component/DependencyInjection/Tests/Fixtures/IteratorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod.php rename to src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedIteratorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod.php index f0fd6f68eb72b..e614931e9fb5b 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/IteratorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedIteratorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod.php @@ -4,7 +4,7 @@ use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; -final class IteratorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod +final class TaggedIteratorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod { public function __construct( #[TaggedIterator(tag: 'foo_bar', defaultIndexMethod: 'getDefaultFooName', defaultPriorityMethod: 'getPriority')] diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/IteratorConsumerWithDefaultPriorityMethod.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedIteratorConsumerWithDefaultPriorityMethod.php similarity index 86% rename from src/Symfony/Component/DependencyInjection/Tests/Fixtures/IteratorConsumerWithDefaultPriorityMethod.php rename to src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedIteratorConsumerWithDefaultPriorityMethod.php index fe78f9c6d0b61..faa544b1a6d25 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/IteratorConsumerWithDefaultPriorityMethod.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedIteratorConsumerWithDefaultPriorityMethod.php @@ -4,7 +4,7 @@ use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; -final class IteratorConsumerWithDefaultPriorityMethod +final class TaggedIteratorConsumerWithDefaultPriorityMethod { public function __construct( #[TaggedIterator(tag: 'foo_bar', defaultPriorityMethod: 'getPriority')] diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumer.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumer.php similarity index 95% rename from src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumer.php rename to src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumer.php index 487cce16c0da8..672389dae8481 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumer.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumer.php @@ -14,7 +14,7 @@ use Psr\Container\ContainerInterface; use Symfony\Component\DependencyInjection\Attribute\TaggedLocator; -final class LocatorConsumer +final class TaggedLocatorConsumer { public function __construct( #[TaggedLocator('foo_bar', indexAttribute: 'foo')] diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerConsumer.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerConsumer.php similarity index 71% rename from src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerConsumer.php rename to src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerConsumer.php index c686754c5ad7e..c40e134a3e8f3 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerConsumer.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerConsumer.php @@ -11,14 +11,14 @@ namespace Symfony\Component\DependencyInjection\Tests\Fixtures; -final class LocatorConsumerConsumer +final class TaggedLocatorConsumerConsumer { public function __construct( - private LocatorConsumer $locatorConsumer + private TaggedLocatorConsumer $locatorConsumer ) { } - public function getLocatorConsumer(): LocatorConsumer + public function getLocatorConsumer(): TaggedLocatorConsumer { return $this->locatorConsumer; } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerFactory.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerFactory.php similarity index 81% rename from src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerFactory.php rename to src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerFactory.php index 4783e0cb609a2..fcdfe489cb7d3 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerFactory.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerFactory.php @@ -14,12 +14,12 @@ use Psr\Container\ContainerInterface; use Symfony\Component\DependencyInjection\Attribute\TaggedLocator; -final class LocatorConsumerFactory +final class TaggedLocatorConsumerFactory { public function __invoke( #[TaggedLocator('foo_bar', indexAttribute: 'key')] ContainerInterface $locator - ): LocatorConsumer { - return new LocatorConsumer($locator); + ): TaggedLocatorConsumer { + return new TaggedLocatorConsumer($locator); } } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerWithDefaultIndexMethod.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerWithDefaultIndexMethod.php similarity index 88% rename from src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerWithDefaultIndexMethod.php rename to src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerWithDefaultIndexMethod.php index 6519e4393a68e..be7e0ae24ccab 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerWithDefaultIndexMethod.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerWithDefaultIndexMethod.php @@ -5,7 +5,7 @@ use Psr\Container\ContainerInterface; use Symfony\Component\DependencyInjection\Attribute\TaggedLocator; -final class LocatorConsumerWithDefaultIndexMethod +final class TaggedLocatorConsumerWithDefaultIndexMethod { public function __construct( #[TaggedLocator(tag: 'foo_bar', defaultIndexMethod: 'getDefaultFooName')] diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod.php similarity index 85% rename from src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod.php rename to src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod.php index f809a8b36ca55..0306b920fa9cf 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod.php @@ -5,7 +5,7 @@ use Psr\Container\ContainerInterface; use Symfony\Component\DependencyInjection\Attribute\TaggedLocator; -final class LocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod +final class TaggedLocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod { public function __construct( #[TaggedLocator(tag: 'foo_bar', defaultIndexMethod: 'getDefaultFooName', defaultPriorityMethod: 'getPriority')] diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerWithDefaultPriorityMethod.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerWithDefaultPriorityMethod.php similarity index 88% rename from src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerWithDefaultPriorityMethod.php rename to src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerWithDefaultPriorityMethod.php index 0fedc2b268089..8904c8a3ecfcf 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerWithDefaultPriorityMethod.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerWithDefaultPriorityMethod.php @@ -5,7 +5,7 @@ use Psr\Container\ContainerInterface; use Symfony\Component\DependencyInjection\Attribute\TaggedLocator; -final class LocatorConsumerWithDefaultPriorityMethod +final class TaggedLocatorConsumerWithDefaultPriorityMethod { public function __construct( #[TaggedLocator(tag: 'foo_bar', defaultPriorityMethod: 'getPriority')] diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerWithoutIndex.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerWithoutIndex.php similarity index 93% rename from src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerWithoutIndex.php rename to src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerWithoutIndex.php index 74b81659527ca..58ea5d8953a33 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/LocatorConsumerWithoutIndex.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedLocatorConsumerWithoutIndex.php @@ -14,7 +14,7 @@ use Psr\Container\ContainerInterface; use Symfony\Component\DependencyInjection\Attribute\TaggedLocator; -final class LocatorConsumerWithoutIndex +final class TaggedLocatorConsumerWithoutIndex { public function __construct( #[TaggedLocator('foo_bar')] diff --git a/src/Symfony/Component/ErrorHandler/ErrorRenderer/HtmlErrorRenderer.php b/src/Symfony/Component/ErrorHandler/ErrorRenderer/HtmlErrorRenderer.php index dd2e83fff153e..c9c45714867c8 100644 --- a/src/Symfony/Component/ErrorHandler/ErrorRenderer/HtmlErrorRenderer.php +++ b/src/Symfony/Component/ErrorHandler/ErrorRenderer/HtmlErrorRenderer.php @@ -262,11 +262,21 @@ private function fileExcerpt(string $file, int $line, int $srcContext = 3): stri // highlight_file could throw warnings // see https://bugs.php.net/25725 $code = @highlight_file($file, true); - // remove main code/span tags - $code = preg_replace('#^\s*(.*)\s*#s', '\\1', $code); - // split multiline spans - $code = preg_replace_callback('#]++)>((?:[^<]*+
)++[^<]*+)
#', fn ($m) => "".str_replace('
', "

", $m[2]).'', $code); - $content = explode('
', $code); + if (\PHP_VERSION_ID >= 80300) { + // remove main pre/code tags + $code = preg_replace('#^\s*(.*)\s*#s', '\\1', $code); + // split multiline code tags + $code = preg_replace_callback('#]++)>((?:[^<]*+\\n)++[^<]*+)#', fn ($m) => "".str_replace("\n", "\n", $m[2]).'', $code); + // Convert spaces to html entities to preserve indentation when rendered + $code = str_replace(' ', ' ', $code); + $content = explode("\n", $code); + } else { + // remove main code/span tags + $code = preg_replace('#^\s*(.*)\s*#s', '\\1', $code); + // split multiline spans + $code = preg_replace_callback('#]++)>((?:[^<]*+
)++[^<]*+)
#', fn ($m) => "".str_replace('
', "

", $m[2]).'', $code); + $content = explode('
', $code); + } $lines = []; if (0 > $srcContext) { diff --git a/src/Symfony/Component/Form/Resources/translations/validators.mk.xlf b/src/Symfony/Component/Form/Resources/translations/validators.mk.xlf new file mode 100644 index 0000000000000..ea86b304cee25 --- /dev/null +++ b/src/Symfony/Component/Form/Resources/translations/validators.mk.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Оваа форма не треба да содржи дополнителни полиња. + + + The uploaded file was too large. Please try to upload a smaller file. + Датотеката што се обидовте да ја подигнете е преголема. Ве молиме обидете се со помала датотека. + + + The CSRF token is invalid. Please try to resubmit the form. + Вашиот CSRF токен е невалиден. Ве молиме испратете ја формата одново. + + + This value is not a valid HTML5 color. + Оваа вредност не е валидна HTML5 боја. + + + Please enter a valid birthdate. + Ве молиме внесете валидна дата на раѓање. + + + The selected choice is invalid. + Избраната опција е невалидна. + + + The collection is invalid. + Колекцијата е невалидна. + + + Please select a valid color. + Ве молиме одберете валидна боја. + + + Please select a valid country. + Ве молиме одберете валидна земја. + + + Please select a valid currency. + Ве молиме одберете валидна валута. + + + Please choose a valid date interval. + Ве молиме одберете валиден интервал помеѓу два датума. + + + Please enter a valid date and time. + Ве молиме внесете валиден датум и време. + + + Please enter a valid date. + Ве молиме внесете валиден датум. + + + Please select a valid file. + Ве молиме одберете валидна датотека. + + + The hidden field is invalid. + Скриеното поле е невалидно. + + + Please enter an integer. + Ве молиме внесете цел број. + + + Please select a valid language. + Ве молиме одберете валиден јазик. + + + Please select a valid locale. + Ве молиме одберете валидна локализација. + + + Please enter a valid money amount. + Ве молиме внесете валидна сума на пари. + + + Please enter a number. + Ве молиме внесете број. + + + The password is invalid. + Лозинката е погрешна. + + + Please enter a percentage value. + Ве молиме внесете процентуална вредност. + + + The values do not match. + Вредностите не се совпаѓаат. + + + Please enter a valid time. + Ве молиме внесете валидно време. + + + Please select a valid timezone. + Ве молиме одберете валидна временска зона. + + + Please enter a valid URL. + Ве молиме внесете валиден униформен локатор на ресурси (URL). + + + Please enter a valid search term. + Ве молиме внесете валиден термин за пребарување. + + + Please provide a valid phone number. + Ве молиме внесете валиден телефонски број. + + + The checkbox has an invalid value. + Полето за штиклирање има неважечка вредност. + + + Please enter a valid email address. + Ве молиме внесете валидна адреса за е-пошта. + + + Please select a valid option. + Ве молиме одберете валидна опција. + + + Please select a valid range. + Ве молиме одберете важечки опсег. + + + Please enter a valid week. + Ве молиме внесете валидна недела. + + + + diff --git a/src/Symfony/Component/HtmlSanitizer/composer.json b/src/Symfony/Component/HtmlSanitizer/composer.json index f09dc0eeebf77..25de651c1e8eb 100644 --- a/src/Symfony/Component/HtmlSanitizer/composer.json +++ b/src/Symfony/Component/HtmlSanitizer/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=8.2", "ext-dom": "*", - "league/uri": "^6.5", + "league/uri": "^6.5|^7.0", "masterminds/html5": "^2.7.2" }, "autoload": { diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index 52ec39ef91ec3..961f09cf42124 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -12,6 +12,7 @@ CHANGELOG * Add `HarFileResponseFactory` testing utility, allow to replay responses from `.har` files * Add `max_retries` option to `RetryableHttpClient` to adjust the retry logic on a per request level * Add `PingWehookMessage` and `PingWebhookMessageHandler` + * Enable using EventSourceHttpClient::connect() for both GET and POST 6.3 --- diff --git a/src/Symfony/Component/HttpClient/EventSourceHttpClient.php b/src/Symfony/Component/HttpClient/EventSourceHttpClient.php index 95baebaf6424c..853657c770eff 100644 --- a/src/Symfony/Component/HttpClient/EventSourceHttpClient.php +++ b/src/Symfony/Component/HttpClient/EventSourceHttpClient.php @@ -39,9 +39,9 @@ public function __construct(HttpClientInterface $client = null, float $reconnect $this->reconnectionTime = $reconnectionTime; } - public function connect(string $url, array $options = []): ResponseInterface + public function connect(string $url, array $options = [], string $method = 'GET'): ResponseInterface { - return $this->request('GET', $url, self::mergeDefaultOptions($options, [ + return $this->request($method, $url, self::mergeDefaultOptions($options, [ 'buffer' => false, 'headers' => [ 'Accept' => 'text/event-stream', diff --git a/src/Symfony/Component/HttpClient/Tests/EventSourceHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/EventSourceHttpClientTest.php index 72eb74fb9f289..e4b1986d34e18 100644 --- a/src/Symfony/Component/HttpClient/Tests/EventSourceHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/EventSourceHttpClientTest.php @@ -110,6 +110,33 @@ public function testGetServerSentEvents() } } + public function testPostServerSentEvents() + { + $chunk = new DataChunk(0, ''); + $response = new MockResponse('', ['canceled' => false, 'http_method' => 'POST', 'url' => 'http://localhost:8080/events', 'response_headers' => ['content-type: text/event-stream']]); + $responseStream = new ResponseStream((function () use ($response, $chunk) { + yield $response => new FirstChunk(); + yield $response => $chunk; + yield $response => new ErrorChunk(0, 'timeout'); + })()); + + $hasCorrectHeaders = function ($options) { + $this->assertSame(['Accept: text/event-stream', 'Cache-Control: no-cache'], $options['headers']); + $this->assertSame('mybody', $options['body']); + + return true; + }; + + $httpClient = $this->createMock(HttpClientInterface::class); + + $httpClient->method('request')->with('POST', 'http://localhost:8080/events', $this->callback($hasCorrectHeaders))->willReturn($response); + + $httpClient->method('stream')->willReturn($responseStream); + + $es = new EventSourceHttpClient($httpClient); + $res = $es->connect('http://localhost:8080/events', ['body' => 'mybody'], 'POST'); + } + /** * @dataProvider contentTypeProvider */ diff --git a/src/Symfony/Component/HttpFoundation/Request.php b/src/Symfony/Component/HttpFoundation/Request.php index 558809b732fbe..74f3ffda6d30d 100644 --- a/src/Symfony/Component/HttpFoundation/Request.php +++ b/src/Symfony/Component/HttpFoundation/Request.php @@ -190,6 +190,9 @@ class Request self::HEADER_X_FORWARDED_PREFIX => 'X_FORWARDED_PREFIX', ]; + /** @var bool */ + private $isIisRewrite = false; + /** * @param array $query The GET parameters * @param array $request The POST parameters @@ -1657,11 +1660,10 @@ protected function prepareRequestUri(): string { $requestUri = ''; - if ('1' == $this->server->get('IIS_WasUrlRewritten') && '' != $this->server->get('UNENCODED_URL')) { + if ($this->isIisRewrite() && '' != $this->server->get('UNENCODED_URL')) { // IIS7 with URL Rewrite: make sure we get the unencoded URL (double slash problem) $requestUri = $this->server->get('UNENCODED_URL'); $this->server->remove('UNENCODED_URL'); - $this->server->remove('IIS_WasUrlRewritten'); } elseif ($this->server->has('REQUEST_URI')) { $requestUri = $this->server->get('REQUEST_URI'); @@ -1858,7 +1860,13 @@ private function setPhpDefaultLocale(string $locale): void */ private function getUrlencodedPrefix(string $string, string $prefix): ?string { - if (!str_starts_with(rawurldecode($string), $prefix)) { + if ($this->isIisRewrite()) { + // ISS with UrlRewriteModule might report SCRIPT_NAME/PHP_SELF with wrong case + // see https://github.com/php/php-src/issues/11981 + if (0 !== stripos(rawurldecode($string), $prefix)) { + return null; + } + } elseif (!str_starts_with(rawurldecode($string), $prefix)) { return null; } @@ -1987,4 +1995,20 @@ private function normalizeAndFilterClientIps(array $clientIps, string $ip): arra // Now the IP chain contains only untrusted proxies and the client IP return $clientIps ? array_reverse($clientIps) : [$firstTrustedIp]; } + + /** + * Is this IIS with UrlRewriteModule? + * + * This method consumes, caches and removed the IIS_WasUrlRewritten env var, + * so we don't inherit it to sub-requests. + */ + private function isIisRewrite(): bool + { + if (1 === $this->server->getInt('IIS_WasUrlRewritten')) { + $this->isIisRewrite = true; + $this->server->remove('IIS_WasUrlRewritten'); + } + + return $this->isIisRewrite; + } } diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/SessionHandlerFactory.php b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/SessionHandlerFactory.php index 13c69b21eb0fe..3f1d03267c5d5 100644 --- a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/SessionHandlerFactory.php +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/SessionHandlerFactory.php @@ -75,8 +75,7 @@ public static function createHandler(object|string $connection, array $options = $config = new Configuration(); $config->setSchemaManagerFactory(new DefaultSchemaManagerFactory()); - $connection = DriverManager::getConnection($params, $config); - $connection = method_exists($connection, 'getNativeConnection') ? $connection->getNativeConnection() : $connection->getWrappedConnection(); + $connection = DriverManager::getConnection($params, $config)->getNativeConnection(); // no break; case str_starts_with($connection, 'mssql://'): diff --git a/src/Symfony/Component/HttpFoundation/Tests/RequestTest.php b/src/Symfony/Component/HttpFoundation/Tests/RequestTest.php index 466e9d5fb10b1..32c7dfe348cfc 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/RequestTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/RequestTest.php @@ -1867,6 +1867,62 @@ public static function getBaseUrlData() ]; } + /** + * @dataProvider baseUriDetectionOnIisWithRewriteData + */ + public function testBaseUriDetectionOnIisWithRewrite(array $server, string $expectedBaseUrl, string $expectedPathInfo) + { + $request = new Request([], [], [], [], [], $server); + + self::assertSame($expectedBaseUrl, $request->getBaseUrl()); + self::assertSame($expectedPathInfo, $request->getPathInfo()); + } + + public static function baseUriDetectionOnIisWithRewriteData(): \Generator + { + yield 'No rewrite' => [ + [ + 'PATH_INFO' => '/foo/bar', + 'PHP_SELF' => '/routingtest/index.php/foo/bar', + 'REQUEST_URI' => '/routingtest/index.php/foo/bar', + 'SCRIPT_FILENAME' => 'C:/Users/derrabus/Projects/routing-test/public/index.php', + 'SCRIPT_NAME' => '/routingtest/index.php', + ], + '/routingtest/index.php', + '/foo/bar', + ]; + + yield 'Rewrite with correct case' => [ + [ + 'IIS_WasUrlRewritten' => '1', + 'PATH_INFO' => '/foo/bar', + 'PHP_SELF' => '/routingtest/index.php/foo/bar', + 'REQUEST_URI' => '/routingtest/foo/bar', + 'SCRIPT_FILENAME' => 'C:/Users/derrabus/Projects/routing-test/public/index.php', + 'SCRIPT_NAME' => '/routingtest/index.php', + 'UNENCODED_URL' => '/routingtest/foo/bar', + ], + '/routingtest', + '/foo/bar', + ]; + + // ISS with UrlRewriteModule might report SCRIPT_NAME/PHP_SELF with wrong case + // see https://github.com/php/php-src/issues/11981 + yield 'Rewrite with case mismatch' => [ + [ + 'IIS_WasUrlRewritten' => '1', + 'PATH_INFO' => '/foo/bar', + 'PHP_SELF' => '/routingtest/index.php/foo/bar', + 'REQUEST_URI' => '/RoutingTest/foo/bar', + 'SCRIPT_FILENAME' => 'C:/Users/derrabus/Projects/routing-test/public/index.php', + 'SCRIPT_NAME' => '/routingtest/index.php', + 'UNENCODED_URL' => '/RoutingTest/foo/bar', + ], + '/RoutingTest', + '/foo/bar', + ]; + } + /** * @dataProvider urlencodedStringPrefixData */ diff --git a/src/Symfony/Component/HttpKernel/DataCollector/DumpDataCollector.php b/src/Symfony/Component/HttpKernel/DataCollector/DumpDataCollector.php index 299ec2c712f19..37f51ae3353b5 100644 --- a/src/Symfony/Component/HttpKernel/DataCollector/DumpDataCollector.php +++ b/src/Symfony/Component/HttpKernel/DataCollector/DumpDataCollector.php @@ -122,9 +122,7 @@ public function collect(Request $request, Response $response, \Throwable $except $dumper->setDisplayOptions(['fileLinkFormat' => $this->fileLinkFormat]); } else { $dumper = new CliDumper('php://output', $this->charset); - if (method_exists($dumper, 'setDisplayOptions')) { - $dumper->setDisplayOptions(['fileLinkFormat' => $this->fileLinkFormat]); - } + $dumper->setDisplayOptions(['fileLinkFormat' => $this->fileLinkFormat]); } foreach ($this->data as $dump) { @@ -238,9 +236,7 @@ public function __destruct() $dumper->setDisplayOptions(['fileLinkFormat' => $this->fileLinkFormat]); } else { $dumper = new CliDumper('php://output', $this->charset); - if (method_exists($dumper, 'setDisplayOptions')) { - $dumper->setDisplayOptions(['fileLinkFormat' => $this->fileLinkFormat]); - } + $dumper->setDisplayOptions(['fileLinkFormat' => $this->fileLinkFormat]); } foreach ($this->data as $i => $dump) { diff --git a/src/Symfony/Component/HttpKernel/Log/DebugLoggerConfigurator.php b/src/Symfony/Component/HttpKernel/Log/DebugLoggerConfigurator.php index 53eb0340f969f..b3e408af84a06 100644 --- a/src/Symfony/Component/HttpKernel/Log/DebugLoggerConfigurator.php +++ b/src/Symfony/Component/HttpKernel/Log/DebugLoggerConfigurator.php @@ -22,7 +22,7 @@ class DebugLoggerConfigurator public function __construct(DebugLoggerInterface $processor, bool $enable = null) { - if ($enable ?? \in_array(\PHP_SAPI, ['cli', 'phpdbg'], true)) { + if ($enable ?? !\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true)) { $this->processor = $processor; } } diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php index 9eda13abf48d2..326551d87b57e 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php @@ -521,7 +521,7 @@ public function testValidationGroupsPassed(string $method, ValueResolver $attrib $payload->title = 'A long title, so the validation passes'; $serializer = new Serializer([new ObjectNormalizer()]); - $validator = (new ValidatorBuilder())->enableAnnotationMapping()->getValidator(); + $validator = (new ValidatorBuilder())->enableAttributeMapping()->getValidator(); $resolver = new RequestPayloadValueResolver($serializer, $validator); $request = Request::create('/', $method, $input); @@ -547,7 +547,7 @@ public function testValidationGroupsNotPassed(string $method, ValueResolver $att $input = ['price' => '50', 'title' => 'Too short']; $serializer = new Serializer([new ObjectNormalizer()]); - $validator = (new ValidatorBuilder())->enableAnnotationMapping()->getValidator(); + $validator = (new ValidatorBuilder())->enableAttributeMapping()->getValidator(); $resolver = new RequestPayloadValueResolver($serializer, $validator); $argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [ diff --git a/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RegisterControllerArgumentLocatorsPassTest.php b/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RegisterControllerArgumentLocatorsPassTest.php index 1a074eb1162f7..584a13f6f5e9c 100644 --- a/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RegisterControllerArgumentLocatorsPassTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RegisterControllerArgumentLocatorsPassTest.php @@ -17,6 +17,7 @@ use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\AutowireCallable; +use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; use Symfony\Component\DependencyInjection\Attribute\TaggedLocator; use Symfony\Component\DependencyInjection\Attribute\Target; @@ -516,7 +517,7 @@ public function testTaggedIteratorAndTaggedLocatorAttributes() /** @var ServiceLocator $locator */ $locator = $container->get($locatorId)->get('foo::fooAction'); - $this->assertCount(2, $locator->getProvidedServices()); + $this->assertCount(3, $locator->getProvidedServices()); $this->assertTrue($locator->has('iterator')); $this->assertInstanceOf(RewindableGenerator::class, $argIterator = $locator->get('iterator')); @@ -529,6 +530,12 @@ public function testTaggedIteratorAndTaggedLocatorAttributes() $this->assertTrue($argLocator->has('baz')); $this->assertSame(iterator_to_array($argIterator), [$argLocator->get('bar'), $argLocator->get('baz')]); + + $this->assertTrue($locator->has('container')); + $this->assertInstanceOf(ServiceLocator::class, $argLocator = $locator->get('container')); + $this->assertCount(2, $argLocator); + $this->assertTrue($argLocator->has('bar')); + $this->assertTrue($argLocator->has('baz')); } } @@ -669,6 +676,7 @@ class WithTaggedIteratorAndTaggedLocator public function fooAction( #[TaggedIterator('foobar')] iterable $iterator, #[TaggedLocator('foobar')] ServiceLocator $locator, + #[AutowireLocator('bar', 'baz')] ContainerInterface $container, ) { } } diff --git a/src/Symfony/Component/HttpKernel/composer.json b/src/Symfony/Component/HttpKernel/composer.json index ccebe6d793e2f..09ac8fe4c162d 100644 --- a/src/Symfony/Component/HttpKernel/composer.json +++ b/src/Symfony/Component/HttpKernel/composer.json @@ -45,7 +45,7 @@ "symfony/validator": "^6.4|^7.0", "symfony/var-exporter": "^6.4|^7.0", "psr/cache": "^1.0|^2.0|^3.0", - "twig/twig": "^2.13|^3.0.4" + "twig/twig": "^3.0.4" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" @@ -67,7 +67,7 @@ "symfony/twig-bridge": "<6.4", "symfony/validator": "<6.4", "symfony/var-dumper": "<6.4", - "twig/twig": "<2.13" + "twig/twig": "<3.0.4" }, "autoload": { "psr-4": { "Symfony\\Component\\HttpKernel\\": "" }, diff --git a/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php index 76e90434354f8..38e85b3c1ba98 100644 --- a/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php @@ -97,6 +97,8 @@ public static function provideDsn() } /** + * @param class-string + * * @dataProvider providePlatforms */ public function testCreatesTableInTransaction(string $platform) @@ -128,7 +130,7 @@ public function testCreatesTableInTransaction(string $platform) ->willReturn(true); $platform = $this->createMock($platform); - $platform->method(method_exists(AbstractPlatform::class, 'getCreateTablesSQL') ? 'getCreateTablesSQL' : 'getCreateTableSQL') + $platform->method('getCreateTablesSQL') ->willReturn(['create sql stmt']); $conn->method('getDatabasePlatform') @@ -144,10 +146,8 @@ public function testCreatesTableInTransaction(string $platform) public static function providePlatforms(): \Generator { yield [\Doctrine\DBAL\Platforms\PostgreSQLPlatform::class]; - yield [\Doctrine\DBAL\Platforms\PostgreSQL94Platform::class]; yield [\Doctrine\DBAL\Platforms\SqlitePlatform::class]; yield [\Doctrine\DBAL\Platforms\SQLServerPlatform::class]; - yield [\Doctrine\DBAL\Platforms\SQLServer2012Platform::class]; } public function testTableCreationInTransactionNotSupported() @@ -178,7 +178,7 @@ public function testTableCreationInTransactionNotSupported() ->willReturn(true); $platform = $this->createMock(AbstractPlatform::class); - $platform->method(method_exists(AbstractPlatform::class, 'getCreateTablesSQL') ? 'getCreateTablesSQL' : 'getCreateTableSQL') + $platform->method('getCreateTablesSQL') ->willReturn(['create sql stmt']); $conn->expects($this->atLeast(2)) @@ -220,7 +220,7 @@ public function testCreatesTableOutsideTransaction() ->willReturn(false); $platform = $this->createMock(AbstractPlatform::class); - $platform->method(method_exists(AbstractPlatform::class, 'getCreateTablesSQL') ? 'getCreateTablesSQL' : 'getCreateTableSQL') + $platform->method('getCreateTablesSQL') ->willReturn(['create sql stmt']); $conn->method('getDatabasePlatform') diff --git a/src/Symfony/Component/Mailer/Bridge/MailerSend/Transport/MailerSendApiTransport.php b/src/Symfony/Component/Mailer/Bridge/MailerSend/Transport/MailerSendApiTransport.php index 3531d94253f55..21c0fe4070d6d 100644 --- a/src/Symfony/Component/Mailer/Bridge/MailerSend/Transport/MailerSendApiTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/MailerSend/Transport/MailerSendApiTransport.php @@ -147,7 +147,7 @@ private function prepareAttachments(Email $email): array $filename = $headers->getHeaderParameter('Content-Disposition', 'filename'); $att = [ - 'content' => $attachment->bodyToString(), + 'content' => base64_encode($attachment->getBody()), 'filename' => $filename, ]; diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/Sendgrid/CHANGELOG.md index 40a44c58c4614..67c32a2d7ebd4 100644 --- a/src/Symfony/Component/Mailer/Bridge/Sendgrid/CHANGELOG.md +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +6.4 +--- + + * Add support for webhooks + 5.4 --- diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/README.md b/src/Symfony/Component/Mailer/Bridge/Sendgrid/README.md index 7482d7b8903a2..a059eb4e90ed5 100644 --- a/src/Symfony/Component/Mailer/Bridge/Sendgrid/README.md +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/README.md @@ -16,6 +16,34 @@ MAILER_DSN=sendgrid+api://KEY@default where: - `KEY` is your Sendgrid API Key + +Webhook +------- + +Create a route: + +```yaml +framework: + webhook: + routing: + sendgrid: + service: mailer.webhook.request_parser.sendgrid + secret: '!SENDGRID_VALIDATION_SECRET!' # Leave blank if you dont want to use the signature validation +``` + +And a consume: + +```php +#[\Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer(name: 'sendgrid')] +class SendGridConsumer implements ConsumerInterface +{ + public function consume(RemoteEvent|MailerDeliveryEvent $event): void + { + // your code + } +} +``` + Resources --------- diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/RemoteEvent/SendgridPayloadConverter.php b/src/Symfony/Component/Mailer/Bridge/Sendgrid/RemoteEvent/SendgridPayloadConverter.php new file mode 100644 index 0000000000000..ff6753384431e --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/RemoteEvent/SendgridPayloadConverter.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent; + +use Symfony\Component\RemoteEvent\Event\Mailer\AbstractMailerEvent; +use Symfony\Component\RemoteEvent\Event\Mailer\MailerDeliveryEvent; +use Symfony\Component\RemoteEvent\Event\Mailer\MailerEngagementEvent; +use Symfony\Component\RemoteEvent\Exception\ParseException; +use Symfony\Component\RemoteEvent\PayloadConverterInterface; + +/** + * @author WoutervanderLoop.nl + */ +final class SendgridPayloadConverter implements PayloadConverterInterface +{ + public function convert(array $payload): AbstractMailerEvent + { + if (\in_array($payload['event'], ['processed', 'delivered', 'bounce', 'dropped', 'deferred'], true)) { + $name = match ($payload['event']) { + 'processed', 'delivered' => MailerDeliveryEvent::DELIVERED, + 'dropped' => MailerDeliveryEvent::DROPPED, + 'deferred' => MailerDeliveryEvent::DEFERRED, + 'bounce' => MailerDeliveryEvent::BOUNCE, + }; + $event = new MailerDeliveryEvent($name, $payload['sg_message_id'], $payload); + $event->setReason($payload['reason'] ?? ''); + } else { + $name = match ($payload['event']) { + 'click' => MailerEngagementEvent::CLICK, + 'unsubscribe' => MailerEngagementEvent::UNSUBSCRIBE, + 'open' => MailerEngagementEvent::OPEN, + 'spamreport' => MailerEngagementEvent::SPAM, + default => throw new ParseException(sprintf('Unsupported event "%s".', $payload['unsubscribe'])), + }; + $event = new MailerEngagementEvent($name, $payload['sg_message_id'], $payload); + } + + if (!$date = \DateTimeImmutable::createFromFormat('U', $payload['timestamp'])) { + throw new ParseException(sprintf('Invalid date "%s".', $payload['timestamp'])); + } + + $event->setDate($date); + $event->setRecipientEmail($payload['email']); + $event->setMetadata([]); + $event->setTags($payload['category'] ?? []); + + return $event; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/Fixtures/webhook.json b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/Fixtures/webhook.json new file mode 100644 index 0000000000000..fed081a6ba9c6 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/Fixtures/webhook.json @@ -0,0 +1 @@ +[{"email":"hello@world.com","event":"dropped","reason":"Bounced Address","sg_event_id":"ZHJvcC0xMDk5NDkxOS1MUnpYbF9OSFN0T0doUTRrb2ZTbV9BLTA","sg_message_id":"LRzXl_NHStOGhQ4kofSm_A.filterdrecv-p3mdw1-756b745b58-kmzbl-18-5F5FC76C-9.0","smtp-id":"","timestamp":1600112492}] diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/Fixtures/webhook.php b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/Fixtures/webhook.php new file mode 100644 index 0000000000000..e762c87a853f8 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/Fixtures/webhook.php @@ -0,0 +1,12 @@ +setRecipientEmail('hello@world.com'); +$wh->setTags([]); +$wh->setMetadata([]); +$wh->setReason('Bounced Address'); +$wh->setDate(\DateTimeImmutable::createFromFormat('U', 1600112492)); + +return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridMissingSignedRequestParserTest.php b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridMissingSignedRequestParserTest.php new file mode 100644 index 0000000000000..f6aa96f702e2a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridMissingSignedRequestParserTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Sendgrid\Tests\Webhook; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter; +use Symfony\Component\Mailer\Bridge\Sendgrid\Webhook\SendgridRequestParser; +use Symfony\Component\Webhook\Client\RequestParserInterface; +use Symfony\Component\Webhook\Exception\RejectWebhookException; +use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase; + +/** + * @author WoutervanderLoop.nl + */ +class SendgridMissingSignedRequestParserTest extends AbstractRequestParserTestCase +{ + protected function createRequestParser(): RequestParserInterface + { + $this->expectException(RejectWebhookException::class); + $this->expectExceptionMessage('Signature is required.'); + + return new SendgridRequestParser(new SendgridPayloadConverter()); + } + + /** + * @see https://github.com/sendgrid/sendgrid-php/blob/9335dca98bc64456a72db73469d1dd67db72f6ea/test/unit/EventWebhookTest.php#L20 + */ + protected function createRequest(string $payload): Request + { + return Request::create('/', 'POST', [], [], [], [ + 'Content-Type' => 'application/json', + ], str_replace("\n", "\r\n", $payload)); + } + + protected function getSecret(): string + { + return 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE83T4O/n84iotIvIW4mdBgQ/7dAfSmpqIM8kF9mN1flpVKS3GRqe62gw+2fNNRaINXvVpiglSI8eNEc6wEA3F+g=='; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridSignedRequestParserTest.php b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridSignedRequestParserTest.php new file mode 100644 index 0000000000000..bc4ed7cd5b28b --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridSignedRequestParserTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Sendgrid\Tests\Webhook; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter; +use Symfony\Component\Mailer\Bridge\Sendgrid\Webhook\SendgridRequestParser; +use Symfony\Component\Webhook\Client\RequestParserInterface; +use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase; + +/** + * @author WoutervanderLoop.nl + * + * @requires extension openssl + */ +class SendgridSignedRequestParserTest extends AbstractRequestParserTestCase +{ + protected function createRequestParser(): RequestParserInterface + { + return new SendgridRequestParser(new SendgridPayloadConverter(), true); + } + + /** + * @see https://github.com/sendgrid/sendgrid-php/blob/9335dca98bc64456a72db73469d1dd67db72f6ea/test/unit/EventWebhookTest.php#L20 + */ + protected function createRequest(string $payload): Request + { + return Request::create('/', 'POST', [], [], [], [ + 'Content-Type' => 'application/json', + 'HTTP_X-Twilio-Email-Event-Webhook-Signature' => 'MEUCIGHQVtGj+Y3LkG9fLcxf3qfI10QysgDWmMOVmxG0u6ZUAiEAyBiXDWzM+uOe5W0JuG+luQAbPIqHh89M15TluLtEZtM=', + 'HTTP_X-Twilio-Email-Event-Webhook-Timestamp' => '1600112502', + ], str_replace("\n", "\r\n", $payload)); + } + + protected function getSecret(): string + { + return 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE83T4O/n84iotIvIW4mdBgQ/7dAfSmpqIM8kF9mN1flpVKS3GRqe62gw+2fNNRaINXvVpiglSI8eNEc6wEA3F+g=='; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridUnsignedRequestParserTest.php b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridUnsignedRequestParserTest.php new file mode 100644 index 0000000000000..b93a327ecc912 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridUnsignedRequestParserTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Sendgrid\Tests\Webhook; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter; +use Symfony\Component\Mailer\Bridge\Sendgrid\Webhook\SendgridRequestParser; +use Symfony\Component\Webhook\Client\RequestParserInterface; +use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase; + +/** + * @author WoutervanderLoop.nl + */ +class SendgridUnsignedRequestParserTest extends AbstractRequestParserTestCase +{ + protected function createRequestParser(): RequestParserInterface + { + return new SendgridRequestParser(new SendgridPayloadConverter()); + } + + /** + * @see https://github.com/sendgrid/sendgrid-php/blob/9335dca98bc64456a72db73469d1dd67db72f6ea/test/unit/EventWebhookTest.php#L20 + */ + protected function createRequest(string $payload): Request + { + return Request::create('/', 'POST', [], [], [], [ + 'Content-Type' => 'application/json', + ], str_replace("\n", "\r\n", $payload)); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridWrongSecretRequestParserTest.php b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridWrongSecretRequestParserTest.php new file mode 100644 index 0000000000000..055bc84a9d59d --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridWrongSecretRequestParserTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Sendgrid\Tests\Webhook; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter; +use Symfony\Component\Mailer\Bridge\Sendgrid\Webhook\SendgridRequestParser; +use Symfony\Component\Webhook\Client\RequestParserInterface; +use Symfony\Component\Webhook\Exception\RejectWebhookException; +use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase; + +/** + * @author WoutervanderLoop.nl + * + * @requires extension openssl + */ +class SendgridWrongSecretRequestParserTest extends AbstractRequestParserTestCase +{ + protected function createRequestParser(): RequestParserInterface + { + $this->expectException(RejectWebhookException::class); + $this->expectExceptionMessage('Public key is wrong.'); + + return new SendgridRequestParser(new SendgridPayloadConverter()); + } + + /** + * @see https://github.com/sendgrid/sendgrid-php/blob/9335dca98bc64456a72db73469d1dd67db72f6ea/test/unit/EventWebhookTest.php#L20 + */ + protected function createRequest(string $payload): Request + { + return Request::create('/', 'POST', [], [], [], [ + 'Content-Type' => 'application/json', + 'HTTP_X-Twilio-Email-Event-Webhook-Signature' => 'MEUCIGHQVtGj+Y3LkG9fLcxf3qfI10QysgDWmMOVmxG0u6ZUAiEAyBiXDWzM+uOe5W0JuG+luQAbPIqHh89M15TluLtEZtM=', + 'HTTP_X-Twilio-Email-Event-Webhook-Timestamp' => '1600112502', + ], str_replace("\n", "\r\n", $payload)); + } + + protected function getSecret(): string + { + return 'incorrect'; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridWrongSignatureRequestParserTest.php b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridWrongSignatureRequestParserTest.php new file mode 100644 index 0000000000000..0b2cfe2bf8615 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Webhook/SendgridWrongSignatureRequestParserTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Sendgrid\Tests\Webhook; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter; +use Symfony\Component\Mailer\Bridge\Sendgrid\Webhook\SendgridRequestParser; +use Symfony\Component\Webhook\Client\RequestParserInterface; +use Symfony\Component\Webhook\Exception\RejectWebhookException; +use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase; + +/** + * @author WoutervanderLoop.nl + * + * @requires extension openssl + */ +class SendgridWrongSignatureRequestParserTest extends AbstractRequestParserTestCase +{ + protected function createRequestParser(): RequestParserInterface + { + $this->expectException(RejectWebhookException::class); + $this->expectExceptionMessage('Signature is wrong.'); + + return new SendgridRequestParser(new SendgridPayloadConverter()); + } + + /** + * @see https://github.com/sendgrid/sendgrid-php/blob/9335dca98bc64456a72db73469d1dd67db72f6ea/test/unit/EventWebhookTest.php#L20 + */ + protected function createRequest(string $payload): Request + { + return Request::create('/', 'POST', [], [], [], [ + 'Content-Type' => 'application/json', + 'HTTP_X-Twilio-Email-Event-Webhook-Signature' => 'incorrect', + 'HTTP_X-Twilio-Email-Event-Webhook-Timestamp' => '1600112502', + ], str_replace("\n", "\r\n", $payload)); + } + + protected function getSecret(): string + { + return 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE83T4O/n84iotIvIW4mdBgQ/7dAfSmpqIM8kF9mN1flpVKS3GRqe62gw+2fNNRaINXvVpiglSI8eNEc6wEA3F+g=='; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/Webhook/SendgridRequestParser.php b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Webhook/SendgridRequestParser.php new file mode 100644 index 0000000000000..ecae4205ccc4b --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Webhook/SendgridRequestParser.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Sendgrid\Webhook; + +use Symfony\Component\HttpFoundation\ChainRequestMatcher; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcherInterface; +use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter; +use Symfony\Component\RemoteEvent\Event\Mailer\AbstractMailerEvent; +use Symfony\Component\RemoteEvent\Exception\ParseException; +use Symfony\Component\Webhook\Client\AbstractRequestParser; +use Symfony\Component\Webhook\Exception\RejectWebhookException; + +/** + * @author WoutervanderLoop.nl + */ +final class SendgridRequestParser extends AbstractRequestParser +{ + public function __construct( + private readonly SendgridPayloadConverter $converter, + ) { + } + + protected function getRequestMatcher(): RequestMatcherInterface + { + return new ChainRequestMatcher([ + new MethodRequestMatcher('POST'), + new IsJsonRequestMatcher(), + ]); + } + + protected function doParse(Request $request, string $secret): ?AbstractMailerEvent + { + $content = $request->toArray(); + if ( + !isset($content[0]['email']) + || !isset($content[0]['timestamp']) + || !isset($content[0]['event']) + || !isset($content[0]['sg_message_id']) + ) { + throw new RejectWebhookException(406, 'Payload is malformed.'); + } + + if ($secret) { + if (!$request->headers->get('X-Twilio-Email-Event-Webhook-Signature') + || !$request->headers->get('X-Twilio-Email-Event-Webhook-Timestamp') + ) { + throw new RejectWebhookException(406, 'Signature is required.'); + } + + $this->validateSignature( + $request->headers->get('X-Twilio-Email-Event-Webhook-Signature'), + $request->headers->get('X-Twilio-Email-Event-Webhook-Timestamp'), + $request->getContent(), + $secret, + ); + } + + try { + return $this->converter->convert($content[0]); + } catch (ParseException $e) { + throw new RejectWebhookException(406, $e->getMessage(), $e); + } + } + + /** + * Verify signed event webhook requests. + * + * @param string $signature value obtained from the + * 'X-Twilio-Email-Event-Webhook-Signature' header + * @param string $timestamp value obtained from the + * 'X-Twilio-Email-Event-Webhook-Timestamp' header + * @param string $payload event payload in the request body + * @param string $secret base64-encoded DER public key + * + * @see https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features + */ + private function validateSignature( + string $signature, + string $timestamp, + string $payload, + string $secret, + ): void { + $timestampedPayload = $timestamp.$payload; + + // Sendgrid provides the verification key as base64-encoded DER data. Openssl wants a PEM format, which is a multiline version of the base64 data. + $pemKey = "-----BEGIN PUBLIC KEY-----\n".chunk_split($secret, 64, "\n")."-----END PUBLIC KEY-----\n"; + + if (!$publicKey = openssl_pkey_get_public($pemKey)) { + throw new RejectWebhookException(406, 'Public key is wrong.'); + } + + if (1 !== openssl_verify($timestampedPayload, base64_decode($signature), $publicKey, \OPENSSL_ALGO_SHA256)) { + throw new RejectWebhookException(406, 'Signature is wrong.'); + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json b/src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json index 4e8d020e581aa..f8982a448063f 100644 --- a/src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json @@ -20,10 +20,12 @@ "symfony/mailer": "^6.4|^7.0" }, "require-dev": { - "symfony/http-client": "^6.4|^7.0" + "symfony/http-client": "^6.4|^7.0", + "symfony/webhook": "^6.4|^7.0" }, "conflict": { - "symfony/mime": "<6.4" + "symfony/mime": "<6.4", + "symfony/http-foundation": "<6.4" }, "autoload": { "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Sendgrid\\": "" }, diff --git a/src/Symfony/Component/Mailer/CHANGELOG.md b/src/Symfony/Component/Mailer/CHANGELOG.md index 3ac8882d352bb..c66b057c5c090 100644 --- a/src/Symfony/Component/Mailer/CHANGELOG.md +++ b/src/Symfony/Component/Mailer/CHANGELOG.md @@ -6,6 +6,11 @@ CHANGELOG * Remove the OhMySmtp bridge in favor of the MailPace bridge +6.4 +--- + + * Add DSN parameter `peer_fingerprint` to verify TLS certificate fingerprint + 6.3 --- diff --git a/src/Symfony/Component/Mailer/Tests/Transport/Smtp/EsmtpTransportFactoryTest.php b/src/Symfony/Component/Mailer/Tests/Transport/Smtp/EsmtpTransportFactoryTest.php index bcdf669be2b2a..c2868ccbd8e99 100644 --- a/src/Symfony/Component/Mailer/Tests/Transport/Smtp/EsmtpTransportFactoryTest.php +++ b/src/Symfony/Component/Mailer/Tests/Transport/Smtp/EsmtpTransportFactoryTest.php @@ -109,6 +109,23 @@ public static function createProvider(): iterable $transport, ]; + $transport = new EsmtpTransport('example.com', 465, true, null, $logger); + /** @var SocketStream $stream */ + $stream = $transport->getStream(); + $streamOptions = $stream->getStreamOptions(); + $streamOptions['ssl']['peer_fingerprint'] = '6A1CF3B08D175A284C30BC10DE19162307C7286E'; + $stream->setStreamOptions($streamOptions); + + yield [ + new Dsn('smtps', 'example.com', '', '', 465, ['peer_fingerprint' => '6A1CF3B08D175A284C30BC10DE19162307C7286E']), + $transport, + ]; + + yield [ + Dsn::fromString('smtps://:@example.com?peer_fingerprint=6A1CF3B08D175A284C30BC10DE19162307C7286E'), + $transport, + ]; + $transport = new EsmtpTransport('example.com', 465, true, null, $logger); $transport->setLocalDomain('example.com'); diff --git a/src/Symfony/Component/Mailer/Tests/Transport/Smtp/EsmtpTransportTest.php b/src/Symfony/Component/Mailer/Tests/Transport/Smtp/EsmtpTransportTest.php index 39dea5d3f3e62..8e7832258bd47 100644 --- a/src/Symfony/Component/Mailer/Tests/Transport/Smtp/EsmtpTransportTest.php +++ b/src/Symfony/Component/Mailer/Tests/Transport/Smtp/EsmtpTransportTest.php @@ -192,6 +192,28 @@ public function testSetAuthenticators() $stream->getCommands() ); } + + public function testConstructorWithEmptyAuthenticator() + { + $stream = new DummyStream(); + $transport = new EsmtpTransport(stream: $stream); + $transport->setUsername('testuser'); + $transport->setPassword('p4ssw0rd'); + $transport->setAuthenticators([]); // if no authenticators defined, then there needs to be a TransportException + + $message = new Email(); + $message->from('sender@example.org'); + $message->addTo('recipient@example.org'); + $message->text('.'); + + try { + $transport->send($message); + $this->fail('Symfony\Component\Mailer\Exception\TransportException to be thrown'); + } catch (TransportException $e) { + $this->assertStringStartsWith('Failed to find an authenticator supported by the SMTP server, which currently supports: "plain", "login", "cram-md5", "xoauth2".', $e->getMessage()); + $this->assertEquals(504, $e->getCode()); + } + } } class CustomEsmtpTransport extends EsmtpTransport diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php b/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php index 83aef862155bf..2683050c9cb22 100644 --- a/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php +++ b/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php @@ -184,6 +184,7 @@ private function handleAuth(array $modes): void return; } + $code = null; $authNames = []; $errors = []; $modes = array_map('strtolower', $modes); diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransportFactory.php b/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransportFactory.php index 7dfa395f272ed..a15d12245d19b 100644 --- a/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransportFactory.php +++ b/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransportFactory.php @@ -29,17 +29,21 @@ public function create(Dsn $dsn): TransportInterface $transport = new EsmtpTransport($host, $port, $tls, $this->dispatcher, $this->logger); - if ('' !== $dsn->getOption('verify_peer') && !filter_var($dsn->getOption('verify_peer', true), \FILTER_VALIDATE_BOOL)) { - /** @var SocketStream $stream */ - $stream = $transport->getStream(); - $streamOptions = $stream->getStreamOptions(); + /** @var SocketStream $stream */ + $stream = $transport->getStream(); + $streamOptions = $stream->getStreamOptions(); + if ('' !== $dsn->getOption('verify_peer') && !filter_var($dsn->getOption('verify_peer', true), \FILTER_VALIDATE_BOOL)) { $streamOptions['ssl']['verify_peer'] = false; $streamOptions['ssl']['verify_peer_name'] = false; + } - $stream->setStreamOptions($streamOptions); + if (null !== $peerFingerprint = $dsn->getOption('peer_fingerprint')) { + $streamOptions['ssl']['peer_fingerprint'] = $peerFingerprint; } + $stream->setStreamOptions($streamOptions); + if ($user = $dsn->getUser()) { $transport->setUsername($user); } diff --git a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransportFactory.php b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransportFactory.php index 20677937b921e..5ffac55ae3f91 100644 --- a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransportFactory.php +++ b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransportFactory.php @@ -18,6 +18,8 @@ /** * @author Jérémy Derussé + * + * @implements TransportFactoryInterface */ class AmazonSqsTransportFactory implements TransportFactoryInterface { diff --git a/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpTransportFactory.php b/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpTransportFactory.php index 34f6d473373de..8781c1eec91d6 100644 --- a/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpTransportFactory.php +++ b/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpTransportFactory.php @@ -17,6 +17,8 @@ /** * @author Samuel Roze + * + * @implements TransportFactoryInterface */ class AmqpTransportFactory implements TransportFactoryInterface { diff --git a/src/Symfony/Component/Messenger/Bridge/Beanstalkd/Transport/BeanstalkdTransportFactory.php b/src/Symfony/Component/Messenger/Bridge/Beanstalkd/Transport/BeanstalkdTransportFactory.php index 26c65ecd1bfa0..4948879f75fc6 100644 --- a/src/Symfony/Component/Messenger/Bridge/Beanstalkd/Transport/BeanstalkdTransportFactory.php +++ b/src/Symfony/Component/Messenger/Bridge/Beanstalkd/Transport/BeanstalkdTransportFactory.php @@ -17,6 +17,8 @@ /** * @author Antonio Pauletich + * + * @implements TransportFactoryInterface */ class BeanstalkdTransportFactory implements TransportFactoryInterface { diff --git a/src/Symfony/Component/Messenger/Bridge/Doctrine/Tests/Transport/DoctrineIntegrationTest.php b/src/Symfony/Component/Messenger/Bridge/Doctrine/Tests/Transport/DoctrineIntegrationTest.php index 42653c1a52dca..64ac008a59444 100644 --- a/src/Symfony/Component/Messenger/Bridge/Doctrine/Tests/Transport/DoctrineIntegrationTest.php +++ b/src/Symfony/Component/Messenger/Bridge/Doctrine/Tests/Transport/DoctrineIntegrationTest.php @@ -13,7 +13,6 @@ use Doctrine\DBAL\Configuration; use Doctrine\DBAL\DriverManager; -use Doctrine\DBAL\Result; use Doctrine\DBAL\Schema\DefaultSchemaManagerFactory; use Doctrine\DBAL\Tools\DsnParser; use PHPUnit\Framework\TestCase; @@ -56,18 +55,16 @@ public function testSendWithDelay() { $this->connection->send('{"message": "Hi i am delayed"}', ['type' => DummyMessage::class], 600000); - $stmt = $this->driverConnection->createQueryBuilder() + $qb = $this->driverConnection->createQueryBuilder() ->select('m.available_at') ->from('messenger_messages', 'm') ->where('m.body = :body') ->setParameter('body', '{"message": "Hi i am delayed"}'); - if (method_exists($stmt, 'executeQuery')) { - $stmt = $stmt->executeQuery(); - } else { - $stmt = $stmt->execute(); - } - $available_at = new \DateTimeImmutable($stmt instanceof Result ? $stmt->fetchOne() : $stmt->fetchColumn()); + // DBAL 2 compatibility + $result = method_exists($qb, 'executeQuery') ? $qb->executeQuery() : $qb->execute(); + + $available_at = new \DateTimeImmutable($result->fetchOne()); $now = new \DateTimeImmutable('now + 60 seconds'); $this->assertGreaterThan($now, $available_at); @@ -183,7 +180,7 @@ public function testTheTransportIsSetupOnGet() $this->assertEquals('the body', $envelope['body']); } - private function formatDateTime(\DateTimeImmutable $dateTime) + private function formatDateTime(\DateTimeImmutable $dateTime): string { return $dateTime->format($this->driverConnection->getDatabasePlatform()->getDateTimeFormatString()); } diff --git a/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/DoctrineTransportFactory.php b/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/DoctrineTransportFactory.php index cff92bf11c19f..5b1d126044d5a 100644 --- a/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/DoctrineTransportFactory.php +++ b/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/DoctrineTransportFactory.php @@ -20,6 +20,8 @@ /** * @author Vincent Touzet + * + * @implements TransportFactoryInterface */ class DoctrineTransportFactory implements TransportFactoryInterface { diff --git a/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/PostgreSqlConnection.php b/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/PostgreSqlConnection.php index b51432cb2d769..4605c6b42937c 100644 --- a/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/PostgreSqlConnection.php +++ b/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/PostgreSqlConnection.php @@ -64,16 +64,10 @@ public function get(): ?array // https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS $this->executeStatement(sprintf('LISTEN "%s"', $this->configuration['table_name'])); - if (method_exists($this->driverConnection, 'getNativeConnection')) { - $wrappedConnection = $this->driverConnection->getNativeConnection(); - } else { - $wrappedConnection = $this->driverConnection; - while (method_exists($wrappedConnection, 'getWrappedConnection')) { - $wrappedConnection = $wrappedConnection->getWrappedConnection(); - } - } + /** @var \PDO $nativeConnection */ + $nativeConnection = $this->driverConnection->getNativeConnection(); - $notification = $wrappedConnection->pgsqlGetNotify(\PDO::FETCH_ASSOC, $this->configuration['get_notify_timeout']); + $notification = $nativeConnection->pgsqlGetNotify(\PDO::FETCH_ASSOC, $this->configuration['get_notify_timeout']); if ( // no notifications, or for another table or queue (false === $notification || $notification['message'] !== $this->configuration['table_name'] || $notification['payload'] !== $this->configuration['queue_name']) diff --git a/src/Symfony/Component/Messenger/Bridge/Redis/Transport/RedisTransportFactory.php b/src/Symfony/Component/Messenger/Bridge/Redis/Transport/RedisTransportFactory.php index 5b95da565971f..f7e9956809ba2 100644 --- a/src/Symfony/Component/Messenger/Bridge/Redis/Transport/RedisTransportFactory.php +++ b/src/Symfony/Component/Messenger/Bridge/Redis/Transport/RedisTransportFactory.php @@ -18,6 +18,8 @@ /** * @author Alexander Schranz * @author Antoine Bluchet + * + * @implements TransportFactoryInterface */ class RedisTransportFactory implements TransportFactoryInterface { diff --git a/src/Symfony/Component/Messenger/README.md b/src/Symfony/Component/Messenger/README.md index 8cd03f157aec0..18187823865a9 100644 --- a/src/Symfony/Component/Messenger/README.md +++ b/src/Symfony/Component/Messenger/README.md @@ -7,7 +7,7 @@ other applications or via message queues. Sponsor ------- -The Messenger component for Symfony 6.2 is [backed][1] by [SensioLabs][2]. +The Messenger component for Symfony 6.4 is [backed][1] by [SensioLabs][2]. As the creator of Symfony, SensioLabs supports companies using Symfony, with an offering encompassing consultancy, expertise, services, training, and technical diff --git a/src/Symfony/Component/Messenger/Transport/InMemory/InMemoryTransportFactory.php b/src/Symfony/Component/Messenger/Transport/InMemory/InMemoryTransportFactory.php index 619ebe8833c0c..42e8ca9adc188 100644 --- a/src/Symfony/Component/Messenger/Transport/InMemory/InMemoryTransportFactory.php +++ b/src/Symfony/Component/Messenger/Transport/InMemory/InMemoryTransportFactory.php @@ -18,6 +18,8 @@ /** * @author Gary PEGEOT + * + * @implements TransportFactoryInterface */ class InMemoryTransportFactory implements TransportFactoryInterface, ResetInterface { diff --git a/src/Symfony/Component/Messenger/Transport/Sync/SyncTransportFactory.php b/src/Symfony/Component/Messenger/Transport/Sync/SyncTransportFactory.php index c4c9ebb6bebea..6136d61c3619d 100644 --- a/src/Symfony/Component/Messenger/Transport/Sync/SyncTransportFactory.php +++ b/src/Symfony/Component/Messenger/Transport/Sync/SyncTransportFactory.php @@ -18,6 +18,8 @@ /** * @author Ryan Weaver + * + * @implements TransportFactoryInterface */ class SyncTransportFactory implements TransportFactoryInterface { diff --git a/src/Symfony/Component/Messenger/Transport/TransportFactory.php b/src/Symfony/Component/Messenger/Transport/TransportFactory.php index 987f19d2a74bf..6dca182be3d2e 100644 --- a/src/Symfony/Component/Messenger/Transport/TransportFactory.php +++ b/src/Symfony/Component/Messenger/Transport/TransportFactory.php @@ -16,6 +16,8 @@ /** * @author Samuel Roze + * + * @implements TransportFactoryInterface */ class TransportFactory implements TransportFactoryInterface { diff --git a/src/Symfony/Component/Messenger/Transport/TransportFactoryInterface.php b/src/Symfony/Component/Messenger/Transport/TransportFactoryInterface.php index f70d45c8e0889..f1a85f960412f 100644 --- a/src/Symfony/Component/Messenger/Transport/TransportFactoryInterface.php +++ b/src/Symfony/Component/Messenger/Transport/TransportFactoryInterface.php @@ -17,9 +17,14 @@ * Creates a Messenger transport. * * @author Samuel Roze + * + * @template-covariant TTransport of TransportInterface */ interface TransportFactoryInterface { + /** + * @return TTransport + */ public function createTransport(#[\SensitiveParameter] string $dsn, array $options, SerializerInterface $serializer): TransportInterface; public function supports(#[\SensitiveParameter] string $dsn, array $options): bool; diff --git a/src/Symfony/Component/Notifier/README.md b/src/Symfony/Component/Notifier/README.md index 672f2fc075acd..016454c480b0d 100644 --- a/src/Symfony/Component/Notifier/README.md +++ b/src/Symfony/Component/Notifier/README.md @@ -6,19 +6,7 @@ The Notifier component sends notifications via one or more channels (email, SMS, Sponsor ------- -The Notifier component for Symfony 6.2 is [backed][1] by [Prisma Media][2]. - -Prisma Media has become in 40 years the n°1 French publishing group, on print and -digitally, with 20 flagship brands of the news magazines : Femme Actuelle, GEO, -Capital, Gala or Télé-Loisirs… Today, more than 42 million French people are in -contact with one of our brand each month, either by leafing through a magazine, -surfing the web, subscribing one our mobile or tablet application or listening to -our podcasts' series. Prisma Media has successfully transformed one's business -model : from a historic player in the world of paper, it has become in 5 years -one of the first publishers of multi-media editorial content, and one of the -first creators of digital solutions. - -Help Symfony by [sponsoring][3] its development! +Help Symfony by [sponsoring][1] its development! Resources --------- @@ -29,6 +17,4 @@ Resources [send Pull Requests](https://github.com/symfony/symfony/pulls) in the [main Symfony repository](https://github.com/symfony/symfony) -[1]: https://symfony.com/backers -[2]: https://www.prismamedia.com -[3]: https://symfony.com/sponsor +[1]: https://symfony.com/sponsor diff --git a/src/Symfony/Component/PasswordHasher/Hasher/Pbkdf2PasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/Pbkdf2PasswordHasher.php index f393e0fc02c3f..0c314d1ae2e1c 100644 --- a/src/Symfony/Component/PasswordHasher/Hasher/Pbkdf2PasswordHasher.php +++ b/src/Symfony/Component/PasswordHasher/Hasher/Pbkdf2PasswordHasher.php @@ -69,7 +69,7 @@ public function hash(#[\SensitiveParameter] string $plainPassword, string $salt throw new LogicException(sprintf('The algorithm "%s" is not supported.', $this->algorithm)); } - $digest = hash_pbkdf2($this->algorithm, $plainPassword, $salt, $this->iterations, $this->length, true); + $digest = hash_pbkdf2($this->algorithm, $plainPassword, $salt ?? '', $this->iterations, $this->length, true); return $this->encodeHashAsBase64 ? base64_encode($digest) : bin2hex($digest); } diff --git a/src/Symfony/Component/PasswordHasher/Tests/Fixtures/TestLegacyPasswordAuthenticatedUser.php b/src/Symfony/Component/PasswordHasher/Tests/Fixtures/TestLegacyPasswordAuthenticatedUser.php index 8ae6bdfe4dd24..6a9b4ea179c86 100644 --- a/src/Symfony/Component/PasswordHasher/Tests/Fixtures/TestLegacyPasswordAuthenticatedUser.php +++ b/src/Symfony/Component/PasswordHasher/Tests/Fixtures/TestLegacyPasswordAuthenticatedUser.php @@ -41,11 +41,6 @@ public function eraseCredentials(): void return; } - public function getUsername(): string - { - return $this->username; - } - public function getUserIdentifier(): string { return $this->username; diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/PasswordHasherFactoryTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/PasswordHasherFactoryTest.php index b87a850ed43f0..a52e235a20c7e 100644 --- a/src/Symfony/Component/PasswordHasher/Tests/Hasher/PasswordHasherFactoryTest.php +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/PasswordHasherFactoryTest.php @@ -199,10 +199,6 @@ public function getSalt(): ?string { } - public function getUsername(): string - { - } - public function getUserIdentifier(): string { } diff --git a/src/Symfony/Component/Process/Tests/Messenger/RunProcessMessageHandlerTest.php b/src/Symfony/Component/Process/Tests/Messenger/RunProcessMessageHandlerTest.php index 10ed9bb2014a6..d406d24339c0b 100644 --- a/src/Symfony/Component/Process/Tests/Messenger/RunProcessMessageHandlerTest.php +++ b/src/Symfony/Component/Process/Tests/Messenger/RunProcessMessageHandlerTest.php @@ -33,7 +33,7 @@ public function testRunFailedProcess() (new RunProcessMessageHandler())(new RunProcessMessage(['invalid'])); } catch (RunProcessFailedException $e) { $this->assertSame(['invalid'], $e->context->command); - $this->assertSame(127, $e->context->exitCode); + $this->assertSame('\\' === \DIRECTORY_SEPARATOR ? 1 : 127, $e->context->exitCode); return; } diff --git a/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php b/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php index 0e6454206c895..7ef020cefef23 100644 --- a/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php +++ b/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php @@ -42,8 +42,7 @@ public function getProperties(string $class, array $context = []): ?array $serializerClassMetadata = $this->classMetadataFactory->getMetadataFor($class); foreach ($serializerClassMetadata->getAttributesMetadata() as $serializerAttributeMetadata) { - $ignored = method_exists($serializerAttributeMetadata, 'isIgnored') && $serializerAttributeMetadata->isIgnored(); - if (!$ignored && (null === $context['serializer_groups'] || array_intersect($context['serializer_groups'], $serializerAttributeMetadata->getGroups()))) { + if (!$serializerAttributeMetadata->isIgnored() && (null === $context['serializer_groups'] || array_intersect($context['serializer_groups'], $serializerAttributeMetadata->getGroups()))) { $properties[] = $serializerAttributeMetadata->getName(); } } diff --git a/src/Symfony/Component/Routing/Loader/DirectoryLoader.php b/src/Symfony/Component/Routing/Loader/DirectoryLoader.php index 5f241bffeb6b1..4ea7c6ba41a5a 100644 --- a/src/Symfony/Component/Routing/Loader/DirectoryLoader.php +++ b/src/Symfony/Component/Routing/Loader/DirectoryLoader.php @@ -45,7 +45,7 @@ public function load(mixed $file, string $type = null): mixed public function supports(mixed $resource, string $type = null): bool { - // only when type is forced to directory, not to conflict with AnnotationLoader + // only when type is forced to directory, not to conflict with AttributeLoader return 'directory' === $type; } diff --git a/src/Symfony/Component/Scheduler/CHANGELOG.md b/src/Symfony/Component/Scheduler/CHANGELOG.md index c1fa4a6406e7f..7730e44695357 100644 --- a/src/Symfony/Component/Scheduler/CHANGELOG.md +++ b/src/Symfony/Component/Scheduler/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Mark the component as non experimental + * [BC BREAK] Add `from()` to `CheckpointInterface` * Add `--date` and `--all` options to the `schedule:debug` command * Allow setting timezone of next run date in CronExpressionTrigger * Add `AbstractTriggerDecorator` diff --git a/src/Symfony/Component/Scheduler/Generator/Checkpoint.php b/src/Symfony/Component/Scheduler/Generator/Checkpoint.php index 05928d78d3f58..0b0e7ae1e5e8e 100644 --- a/src/Symfony/Component/Scheduler/Generator/Checkpoint.php +++ b/src/Symfony/Component/Scheduler/Generator/Checkpoint.php @@ -16,6 +16,7 @@ final class Checkpoint implements CheckpointInterface { + private \DateTimeImmutable $from; private \DateTimeImmutable $time; private int $index = -1; private bool $reset = false; @@ -41,14 +42,22 @@ public function acquire(\DateTimeImmutable $now): bool $this->save($now, -1); } - $this->time ??= $now; if ($this->cache) { - $this->save(...$this->cache->get($this->name, fn () => [$now, -1])); + [$this->time, $this->index, $this->from] = $this->cache->get($this->name, fn () => [$now, -1, $now]) + [2 => $now]; + $this->save($this->time, $this->index); } + $this->time ??= $now; + $this->from ??= $now; + return true; } + public function from(): \DateTimeImmutable + { + return $this->from; + } + public function time(): \DateTimeImmutable { return $this->time; @@ -63,7 +72,8 @@ public function save(\DateTimeImmutable $time, int $index): void { $this->time = $time; $this->index = $index; - $this->cache?->get($this->name, fn () => [$time, $index], \INF); + $this->from ??= $time; + $this->cache?->get($this->name, fn () => [$time, $index, $this->from], \INF); } /** diff --git a/src/Symfony/Component/Scheduler/Generator/CheckpointInterface.php b/src/Symfony/Component/Scheduler/Generator/CheckpointInterface.php index 47e9f53fc0aa8..5e5e0415f1a5d 100644 --- a/src/Symfony/Component/Scheduler/Generator/CheckpointInterface.php +++ b/src/Symfony/Component/Scheduler/Generator/CheckpointInterface.php @@ -15,6 +15,8 @@ interface CheckpointInterface { public function acquire(\DateTimeImmutable $now): bool; + public function from(): \DateTimeImmutable; + public function time(): \DateTimeImmutable; public function index(): int; diff --git a/src/Symfony/Component/Scheduler/Generator/MessageGenerator.php b/src/Symfony/Component/Scheduler/Generator/MessageGenerator.php index a2f59beedeb48..025702f91d7e8 100644 --- a/src/Symfony/Component/Scheduler/Generator/MessageGenerator.php +++ b/src/Symfony/Component/Scheduler/Generator/MessageGenerator.php @@ -16,6 +16,7 @@ use Symfony\Component\Scheduler\RecurringMessage; use Symfony\Component\Scheduler\Schedule; use Symfony\Component\Scheduler\ScheduleProviderInterface; +use Symfony\Component\Scheduler\Trigger\StatefulTriggerInterface; final class MessageGenerator implements MessageGeneratorInterface { @@ -43,9 +44,10 @@ public function getMessages(): \Generator return; } + $startTime = $checkpoint->from(); $lastTime = $checkpoint->time(); $lastIndex = $checkpoint->index(); - $heap = $this->heap($lastTime); + $heap = $this->heap($lastTime, $startTime); while (!$heap->isEmpty() && $heap->top()[0] <= $now) { /** @var \DateTimeImmutable $time */ @@ -79,7 +81,7 @@ public function getMessages(): \Generator $checkpoint->release($now, $this->waitUntil); } - private function heap(\DateTimeImmutable $time): TriggerHeap + private function heap(\DateTimeImmutable $time, \DateTimeImmutable $startTime): TriggerHeap { if (isset($this->triggerHeap) && $this->triggerHeap->time <= $time) { return $this->triggerHeap; @@ -88,7 +90,13 @@ private function heap(\DateTimeImmutable $time): TriggerHeap $heap = new TriggerHeap($time); foreach ($this->schedule()->getRecurringMessages() as $index => $recurringMessage) { - if (!$nextTime = $recurringMessage->getTrigger()->getNextRunDate($time)) { + $trigger = $recurringMessage->getTrigger(); + + if ($trigger instanceof StatefulTriggerInterface) { + $trigger->continue($startTime); + } + + if (!$nextTime = $trigger->getNextRunDate($time)) { continue; } diff --git a/src/Symfony/Component/Scheduler/Messenger/SchedulerTransportFactory.php b/src/Symfony/Component/Scheduler/Messenger/SchedulerTransportFactory.php index f624a2b83ab03..3fe09ebc94921 100644 --- a/src/Symfony/Component/Scheduler/Messenger/SchedulerTransportFactory.php +++ b/src/Symfony/Component/Scheduler/Messenger/SchedulerTransportFactory.php @@ -20,6 +20,9 @@ use Symfony\Component\Scheduler\Generator\MessageGenerator; use Symfony\Component\Scheduler\ScheduleProviderInterface; +/** + * @implements TransportFactoryInterface + */ class SchedulerTransportFactory implements TransportFactoryInterface { public function __construct( diff --git a/src/Symfony/Component/Scheduler/RecurringMessage.php b/src/Symfony/Component/Scheduler/RecurringMessage.php index 2bd3463d3dd02..ba6b2ed8ae0bc 100644 --- a/src/Symfony/Component/Scheduler/RecurringMessage.php +++ b/src/Symfony/Component/Scheduler/RecurringMessage.php @@ -40,7 +40,7 @@ private function __construct( * @see https://en.wikipedia.org/wiki/ISO_8601#Durations * @see https://php.net/datetime.formats.relative */ - public static function every(string|int|\DateInterval $frequency, object $message, string|\DateTimeImmutable $from = new \DateTimeImmutable(), string|\DateTimeImmutable $until = new \DateTimeImmutable('3000-01-01')): self + public static function every(string|int|\DateInterval $frequency, object $message, string|\DateTimeImmutable|null $from = null, string|\DateTimeImmutable $until = new \DateTimeImmutable('3000-01-01')): self { return new self(new PeriodicalTrigger($frequency, $from, $until), $message); } diff --git a/src/Symfony/Component/Scheduler/Tests/Generator/CheckpointTest.php b/src/Symfony/Component/Scheduler/Tests/Generator/CheckpointTest.php index 5f919b5c4e870..b64d3bda3f9fa 100644 --- a/src/Symfony/Component/Scheduler/Tests/Generator/CheckpointTest.php +++ b/src/Symfony/Component/Scheduler/Tests/Generator/CheckpointTest.php @@ -48,7 +48,7 @@ public function testWithStateInitStateOnFirstAcquiring() $this->assertTrue($checkpoint->acquire($now)); $this->assertEquals($now, $checkpoint->time()); $this->assertEquals(-1, $checkpoint->index()); - $this->assertEquals([$now, -1], $cache->get('cache', fn () => [])); + $this->assertEquals([$now, -1, $now], $cache->get('cache', fn () => [])); } public function testWithStateLoadStateOnAcquiring() @@ -58,10 +58,10 @@ public function testWithStateLoadStateOnAcquiring() $cache->get('cache', fn () => [$now, 0], \INF); - $this->assertTrue($checkpoint->acquire($now->modify('1 min'))); + $this->assertTrue($checkpoint->acquire($startedAt = $now->modify('1 min'))); $this->assertEquals($now, $checkpoint->time()); $this->assertEquals(0, $checkpoint->index()); - $this->assertEquals([$now, 0], $cache->get('cache', fn () => [])); + $this->assertEquals([$now, 0, $startedAt], $cache->get('cache', fn () => [])); } public function testWithLockInitStateOnFirstAcquiring() @@ -72,11 +72,12 @@ public function testWithLockInitStateOnFirstAcquiring() $this->assertTrue($checkpoint->acquire($now)); $this->assertEquals($now, $checkpoint->time()); + $this->assertEquals($now, $checkpoint->from()); $this->assertEquals(-1, $checkpoint->index()); $this->assertTrue($lock->isAcquired()); } - public function testwithLockLoadStateOnAcquiring() + public function testWithLockLoadStateOnAcquiring() { $lock = new Lock(new Key('lock'), new InMemoryStore()); $checkpoint = new Checkpoint('dummy', $lock); @@ -86,6 +87,7 @@ public function testwithLockLoadStateOnAcquiring() $this->assertTrue($checkpoint->acquire($now->modify('1 min'))); $this->assertEquals($now, $checkpoint->time()); + $this->assertEquals($now, $checkpoint->from()); $this->assertEquals(0, $checkpoint->index()); $this->assertTrue($lock->isAcquired()); } @@ -105,12 +107,13 @@ public function testWithCacheSave() { $checkpoint = new Checkpoint('cache', new NoLock(), $cache = new ArrayAdapter()); $now = new \DateTimeImmutable('2020-02-20 20:20:20Z'); - $checkpoint->acquire($now->modify('-1 hour')); + $checkpoint->acquire($startedAt = $now->modify('-1 hour')); $checkpoint->save($now, 3); $this->assertSame($now, $checkpoint->time()); $this->assertSame(3, $checkpoint->index()); - $this->assertEquals([$now, 3], $cache->get('cache', fn () => [])); + $this->assertSame($startedAt, $checkpoint->from()); + $this->assertEquals([$now, 3, $startedAt], $cache->get('cache', fn () => [])); } public function testWithLockSave() @@ -119,11 +122,12 @@ public function testWithLockSave() $checkpoint = new Checkpoint('dummy', $lock); $now = new \DateTimeImmutable('2020-02-20 20:20:20Z'); - $checkpoint->acquire($now->modify('-1 hour')); + $checkpoint->acquire($startTime = $now->modify('-1 hour')); $checkpoint->save($now, 3); $this->assertSame($now, $checkpoint->time()); $this->assertSame(3, $checkpoint->index()); + $this->assertSame($startTime, $checkpoint->from()); } public function testWithLockAndCacheSave() @@ -132,12 +136,12 @@ public function testWithLockAndCacheSave() $checkpoint = new Checkpoint('dummy', $lock, $cache = new ArrayAdapter()); $now = new \DateTimeImmutable('2020-02-20 20:20:20Z'); - $checkpoint->acquire($now->modify('-1 hour')); + $checkpoint->acquire($startTime = $now->modify('-1 hour')); $checkpoint->save($now, 3); $this->assertSame($now, $checkpoint->time()); $this->assertSame(3, $checkpoint->index()); - $this->assertEquals([$now, 3], $cache->get('dummy', fn () => [])); + $this->assertEquals([$now, 3, $startTime], $cache->get('dummy', fn () => [])); } public function testWithCacheFullCycle() @@ -161,7 +165,7 @@ public function testWithCacheFullCycle() $this->assertSame(3, $lastIndex); $this->assertEquals($now, $checkpoint->time()); $this->assertSame(0, $checkpoint->index()); - $this->assertEquals([$now, 0], $cache->get('cache', fn () => [])); + $this->assertEquals([$now, 0, $now], $cache->get('cache', fn () => [])); } public function testWithLockResetStateAfterLockedAcquiring() diff --git a/src/Symfony/Component/Scheduler/Tests/SchedulerTest.php b/src/Symfony/Component/Scheduler/Tests/SchedulerTest.php index 770911b54791a..5031e4c405710 100644 --- a/src/Symfony/Component/Scheduler/Tests/SchedulerTest.php +++ b/src/Symfony/Component/Scheduler/Tests/SchedulerTest.php @@ -28,7 +28,7 @@ public function testCanRunAndStop() $scheduler = new Scheduler([Message::class => $handler], [$schedule], $clock); $handler->scheduler = $scheduler; - $scheduler->run(['sleep' => 1]); + $scheduler->run(['sleep' => 100]); $this->assertSame(3, $handler->count); } diff --git a/src/Symfony/Component/Scheduler/Tests/Trigger/PeriodicalTriggerTest.php b/src/Symfony/Component/Scheduler/Tests/Trigger/PeriodicalTriggerTest.php index b5fe148bf3bee..6ed44c9d1f7e5 100644 --- a/src/Symfony/Component/Scheduler/Tests/Trigger/PeriodicalTriggerTest.php +++ b/src/Symfony/Component/Scheduler/Tests/Trigger/PeriodicalTriggerTest.php @@ -23,9 +23,9 @@ class PeriodicalTriggerTest extends TestCase */ public function testConstructor(PeriodicalTrigger $trigger, bool $optimizable = true) { - $run = new \DateTimeImmutable('2922-02-22 13:34:00+00:00'); + $run = new \DateTimeImmutable('2922-02-22 12:34:00+00:00'); - $this->assertSame('2922-02-23 13:34:00+00:00', $trigger->getNextRunDate($run)->format('Y-m-d H:i:sP')); + $this->assertSame('2922-02-23 13:34:00+01:00', $trigger->getNextRunDate($run)->format('Y-m-d H:i:sP')); if ($optimizable) { // test that we are using the fast algorithm for short period of time @@ -36,7 +36,7 @@ public function testConstructor(PeriodicalTrigger $trigger, bool $optimizable = public static function provideForConstructor(): iterable { - $from = new \DateTimeImmutable($now = '2022-02-22 13:34:00+00:00'); + $from = new \DateTimeImmutable($now = '2022-02-22 13:34:00+01:00'); $until = new \DateTimeImmutable($farFuture = '3000-01-01'); yield [new PeriodicalTrigger(86400, $from, $until)]; @@ -181,7 +181,7 @@ public static function providerGetNextRunDateAgain(): iterable yield [ $trigger, new \DateTimeImmutable('2020-02-20T01:59:00+02:00'), - new \DateTimeImmutable('2020-02-20T02:09:00+02:00'), + new \DateTimeImmutable('2020-02-20T02:00:00+02:00'), ]; yield [ $trigger, diff --git a/src/Symfony/Component/Scheduler/Trigger/AbstractDecoratedTrigger.php b/src/Symfony/Component/Scheduler/Trigger/AbstractDecoratedTrigger.php index 8ba5047a664d3..cb528314a66bf 100644 --- a/src/Symfony/Component/Scheduler/Trigger/AbstractDecoratedTrigger.php +++ b/src/Symfony/Component/Scheduler/Trigger/AbstractDecoratedTrigger.php @@ -14,12 +14,19 @@ /** * @author Kevin Bond */ -abstract class AbstractDecoratedTrigger implements TriggerInterface +abstract class AbstractDecoratedTrigger implements StatefulTriggerInterface { public function __construct(private TriggerInterface $inner) { } + public function continue(\DateTimeImmutable $startedAt): void + { + if ($this->inner instanceof StatefulTriggerInterface) { + $this->inner->continue($startedAt); + } + } + final public function inner(): TriggerInterface { $inner = $this->inner; diff --git a/src/Symfony/Component/Scheduler/Trigger/PeriodicalTrigger.php b/src/Symfony/Component/Scheduler/Trigger/PeriodicalTrigger.php index 9da4ff67438fe..9afbe58cd3f89 100644 --- a/src/Symfony/Component/Scheduler/Trigger/PeriodicalTrigger.php +++ b/src/Symfony/Component/Scheduler/Trigger/PeriodicalTrigger.php @@ -13,17 +13,18 @@ use Symfony\Component\Scheduler\Exception\InvalidArgumentException; -class PeriodicalTrigger implements TriggerInterface +class PeriodicalTrigger implements StatefulTriggerInterface { private float $intervalInSeconds = 0.0; - private \DateTimeImmutable $from; + private ?\DateTimeImmutable $from; private \DateTimeImmutable $until; private \DatePeriod $period; private string $description; + private string|int|float|\DateInterval $interval; public function __construct( string|int|float|\DateInterval $interval, - string|\DateTimeImmutable $from = new \DateTimeImmutable(), + string|\DateTimeImmutable|null $from = null, string|\DateTimeImmutable $until = new \DateTimeImmutable('3000-01-01'), ) { $this->from = \is_string($from) ? new \DateTimeImmutable($from) : $from; @@ -70,7 +71,7 @@ public function __construct( $this->description = sprintf('every %s seconds', $this->intervalInSeconds); } } else { - $this->period = new \DatePeriod($this->from, $i, $this->until); + $this->interval = $i; } } catch (\Exception $e) { throw new InvalidArgumentException(sprintf('Invalid interval "%s": ', $interval instanceof \DateInterval ? 'instance of \DateInterval' : $interval).$e->getMessage(), 0, $e); @@ -82,19 +83,26 @@ public function __toString(): string return $this->description; } + public function continue(\DateTimeImmutable $startedAt): void + { + $this->from ??= $startedAt; + } + public function getNextRunDate(\DateTimeImmutable $run): ?\DateTimeImmutable { + $this->from ??= $run; + if ($this->intervalInSeconds) { if ($this->until <= $run) { return null; } - $fromDate = min($this->from, $run); - $from = $fromDate->format('U.u'); + $fromDate = $this->from; + $from = (float) $fromDate->format('U.u'); $delta = $run->format('U.u') - $from; $recurrencesPassed = floor($delta / $this->intervalInSeconds); $nextRunTimestamp = sprintf('%.6F', ($recurrencesPassed + 1) * $this->intervalInSeconds + $from); - $nextRun = \DateTimeImmutable::createFromFormat('U.u', $nextRunTimestamp, $fromDate->getTimezone()); + $nextRun = \DateTimeImmutable::createFromFormat('U.u', $nextRunTimestamp)->setTimezone($fromDate->getTimezone()); if ($this->from > $nextRun) { return $this->from; @@ -103,6 +111,7 @@ public function getNextRunDate(\DateTimeImmutable $run): ?\DateTimeImmutable return $this->until > $nextRun ? $nextRun : null; } + $this->period ??= new \DatePeriod($this->from, $this->interval, $this->until); $iterator = $this->period->getIterator(); while ($run >= $next = $iterator->current()) { $iterator->next(); @@ -126,6 +135,6 @@ private function canBeConvertedToSeconds(\DateInterval $interval): bool private function calcInterval(\DateInterval $interval): float { - return $this->from->setTimestamp(0)->add($interval)->format('U.u'); + return (float) (new \DateTimeImmutable('@0'))->add($interval)->format('U.u'); } } diff --git a/src/Symfony/Component/Scheduler/Trigger/StatefulTriggerInterface.php b/src/Symfony/Component/Scheduler/Trigger/StatefulTriggerInterface.php new file mode 100644 index 0000000000000..307b073c93d83 --- /dev/null +++ b/src/Symfony/Component/Scheduler/Trigger/StatefulTriggerInterface.php @@ -0,0 +1,8 @@ +tokens[$series]->getClass(), - method_exists($this->tokens[$series], 'getUserIdentifier') ? $this->tokens[$series]->getUserIdentifier() : $this->tokens[$series]->getUsername(), + $this->tokens[$series]->getUserIdentifier(), $series, $tokenValue, $lastUsed diff --git a/src/Symfony/Component/Security/Core/README.md b/src/Symfony/Component/Security/Core/README.md index c7385af898948..48ffb0e526184 100644 --- a/src/Symfony/Component/Security/Core/README.md +++ b/src/Symfony/Component/Security/Core/README.md @@ -41,7 +41,7 @@ if (!$accessDecisionManager->decide($token, ['ROLE_ADMIN'])) { Sponsor ------- -The Security component for Symfony 6.3 is [backed][1] by [SymfonyCasts][2]. +The Security component for Symfony 6.4 is [backed][1] by [SymfonyCasts][2]. Learn Symfony faster by watching real projects being built and actively coding along with them. SymfonyCasts bridges that learning gap, bringing you video diff --git a/src/Symfony/Component/Security/Core/Resources/translations/security.mk.xlf b/src/Symfony/Component/Security/Core/Resources/translations/security.mk.xlf new file mode 100644 index 0000000000000..051affcf8b241 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Resources/translations/security.mk.xlf @@ -0,0 +1,83 @@ + + + + + + An authentication exception occurred. + Настана грешка во автентикацијата. + + + Authentication credentials could not be found. + Акредитивите за автентикација не се пронајдени. + + + Authentication request could not be processed due to a system problem. + Барањето за автентикација не можеше да биде процесуирано заради системски проблем. + + + Invalid credentials. + Невалидни акредитиви. + + + Cookie has already been used by someone else. + Колачето е веќе користено од некој друг. + + + Not privileged to request the resource. + Немате привилегии за да го побарате ресурсот. + + + Invalid CSRF token. + Невалиден CSRF токен. + + + No authentication provider found to support the authentication token. + Не е пронајден провајдер за автентикација кој го поддржува токенот за автентикација. + + + No session available, it either timed out or cookies are not enabled. + Сесијата е недостапна, или е истечена, или колачињата не се овозможени. + + + No token could be found. + Токенот не е најден. + + + Username could not be found. + Корисничкото име не е најдено. + + + Account has expired. + Корисничката сметка е истечена. + + + Credentials have expired. + Акредитивите се истечени. + + + Account is disabled. + Корисничката сметка е деактивирана. + + + Account is locked. + Корисничката сметка е заклучена. + + + Too many failed login attempts, please try again later. + Премногу неуспешни обиди за најавување, ве молиме обидете се повторно подоцна. + + + Invalid or expired login link. + Неважечка или истечена врска за најавување. + + + Too many failed login attempts, please try again in %minutes% minute. + Премногу неуспешни обиди за најавување, обидете се повторно за %minutes% минута. + + + Too many failed login attempts, please try again in %minutes% minutes. + Премногу неуспешни обиди за најавување, обидете се повторно за %minutes% минути. + + + + diff --git a/src/Symfony/Component/Security/Core/Tests/Authentication/AuthenticationTrustResolverTest.php b/src/Symfony/Component/Security/Core/Tests/Authentication/AuthenticationTrustResolverTest.php index 02149ce3da711..3e0a8d50955fb 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authentication/AuthenticationTrustResolverTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authentication/AuthenticationTrustResolverTest.php @@ -115,10 +115,6 @@ public function setUser($user): void { } - public function getUsername(): string - { - } - public function getUserIdentifier(): string { } diff --git a/src/Symfony/Component/Security/Core/Tests/Authentication/Token/Fixtures/CustomUser.php b/src/Symfony/Component/Security/Core/Tests/Authentication/Token/Fixtures/CustomUser.php index 52fea7a3ddd6d..9930203236e07 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authentication/Token/Fixtures/CustomUser.php +++ b/src/Symfony/Component/Security/Core/Tests/Authentication/Token/Fixtures/CustomUser.php @@ -17,11 +17,6 @@ public function __construct(string $username, array $roles) $this->roles = $roles; } - public function getUsername(): string - { - return $this->username; - } - public function getUserIdentifier(): string { return $this->username; diff --git a/src/Symfony/Component/Security/Core/Tests/User/ChainUserProviderTest.php b/src/Symfony/Component/Security/Core/Tests/User/ChainUserProviderTest.php index a5a74f0b05651..09227752bb0ee 100644 --- a/src/Symfony/Component/Security/Core/Tests/User/ChainUserProviderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/User/ChainUserProviderTest.php @@ -23,7 +23,7 @@ class ChainUserProviderTest extends TestCase { - public function testLoadUserByUsername() + public function testLoadUserByIdentifier() { $provider1 = $this->createMock(InMemoryUserProvider::class); $provider1 @@ -45,7 +45,7 @@ public function testLoadUserByUsername() $this->assertSame($account, $provider->loadUserByIdentifier('foo')); } - public function testLoadUserByUsernameThrowsUserNotFoundException() + public function testLoadUserByIdentifierThrowsUserNotFoundException() { $this->expectException(UserNotFoundException::class); $provider1 = $this->createMock(InMemoryUserProvider::class); diff --git a/src/Symfony/Component/Security/Core/Tests/User/InMemoryUserProviderTest.php b/src/Symfony/Component/Security/Core/Tests/User/InMemoryUserProviderTest.php index da0d832d846ab..1a843e4e71c55 100644 --- a/src/Symfony/Component/Security/Core/Tests/User/InMemoryUserProviderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/User/InMemoryUserProviderTest.php @@ -68,7 +68,7 @@ public function testCreateUserAlreadyExist() $provider->createUser(new InMemoryUser('fabien', 'foo')); } - public function testLoadUserByUsernameDoesNotExist() + public function testLoadUserByIdentifierDoesNotExist() { $this->expectException(UserNotFoundException::class); $provider = new InMemoryUserProvider(); diff --git a/src/Symfony/Component/Security/Core/Tests/Validator/Constraints/UserPasswordTest.php b/src/Symfony/Component/Security/Core/Tests/Validator/Constraints/UserPasswordTest.php index fae450b6842f5..ed4ca4427798d 100644 --- a/src/Symfony/Component/Security/Core/Tests/Validator/Constraints/UserPasswordTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Validator/Constraints/UserPasswordTest.php @@ -14,7 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Validator\Constraints\UserPassword; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class UserPasswordTest extends TestCase { @@ -40,7 +40,7 @@ public static function provideServiceValidatedConstraints(): iterable yield 'named arguments' => [new UserPassword(service: 'my_service')]; $metadata = new ClassMetadata(UserPasswordDummy::class); - self::assertTrue((new AnnotationLoader())->loadClassMetadata($metadata)); + self::assertTrue((new AttributeLoader())->loadClassMetadata($metadata)); yield 'attribute' => [$metadata->properties['b']->constraints[0]]; } @@ -48,7 +48,7 @@ public static function provideServiceValidatedConstraints(): iterable public function testAttributes() { $metadata = new ClassMetadata(UserPasswordDummy::class); - self::assertTrue((new AnnotationLoader())->loadClassMetadata($metadata)); + self::assertTrue((new AttributeLoader())->loadClassMetadata($metadata)); [$bConstraint] = $metadata->properties['b']->getConstraints(); self::assertSame('myMessage', $bConstraint->message); diff --git a/src/Symfony/Component/Security/Csrf/README.md b/src/Symfony/Component/Security/Csrf/README.md index c5eec93bf36eb..90b7bfe5ea4c5 100644 --- a/src/Symfony/Component/Security/Csrf/README.md +++ b/src/Symfony/Component/Security/Csrf/README.md @@ -7,7 +7,7 @@ The Security CSRF (cross-site request forgery) component provides a class Sponsor ------- -The Security component for Symfony 6.3 is [backed][1] by [SymfonyCasts][2]. +The Security component for Symfony 6.4 is [backed][1] by [SymfonyCasts][2]. Learn Symfony faster by watching real projects being built and actively coding along with them. SymfonyCasts bridges that learning gap, bringing you video diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php index e2b41bdf5c210..774d4f9579a4b 100644 --- a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php +++ b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php @@ -27,6 +27,7 @@ use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\InvalidSignatureException; use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\MissingClaimException; +use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; /** @@ -91,7 +92,7 @@ public function getUserBadgeFrom(string $accessToken): UserBadge } // UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate - return new UserBadge($claims[$this->claim], fn () => $this->createUser($claims), $claims); + return new UserBadge($claims[$this->claim], new FallbackUserLoader(fn () => $this->createUser($claims)), $claims); } catch (\Exception $e) { $this->logger?->error('An error occurred while decoding and validating the token.', [ 'error' => $e->getMessage(), diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcUserInfoTokenHandler.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcUserInfoTokenHandler.php index b7a8581cc133c..58f5041e66bf1 100644 --- a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcUserInfoTokenHandler.php +++ b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcUserInfoTokenHandler.php @@ -15,6 +15,7 @@ use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\MissingClaimException; +use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -46,7 +47,7 @@ public function getUserBadgeFrom(string $accessToken): UserBadge } // UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate - return new UserBadge($claims[$this->claim], fn () => $this->createUser($claims), $claims); + return new UserBadge($claims[$this->claim], new FallbackUserLoader(fn () => $this->createUser($claims)), $claims); } catch (\Exception $e) { $this->logger?->error('An error occurred on OIDC server.', [ 'error' => $e->getMessage(), diff --git a/src/Symfony/Component/Security/Http/Authentication/AuthenticationFailureHandlerInterface.php b/src/Symfony/Component/Security/Http/Authentication/AuthenticationFailureHandlerInterface.php index 6f6cffef1a06e..faf5979c28526 100644 --- a/src/Symfony/Component/Security/Http/Authentication/AuthenticationFailureHandlerInterface.php +++ b/src/Symfony/Component/Security/Http/Authentication/AuthenticationFailureHandlerInterface.php @@ -27,9 +27,7 @@ interface AuthenticationFailureHandlerInterface { /** - * This is called when an interactive authentication attempt fails. This is - * called by authentication listeners inheriting from - * AbstractAuthenticationListener. + * This is called when an interactive authentication attempt fails. */ public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response; } diff --git a/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationFailureHandler.php b/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationFailureHandler.php index 17adf4b305cdd..245e46dea4860 100644 --- a/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationFailureHandler.php +++ b/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationFailureHandler.php @@ -88,7 +88,9 @@ public function onAuthenticationFailure(Request $request, AuthenticationExceptio $this->logger?->debug('Authentication failure, redirect triggered.', ['failure_path' => $options['failure_path']]); - $request->getSession()->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $exception); + if (!$request->attributes->getBoolean('_stateless')) { + $request->getSession()->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $exception); + } return $this->httpUtils->createRedirectResponse($request, $options['failure_path']); } diff --git a/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationSuccessHandler.php b/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationSuccessHandler.php index 51f21569125c4..6dc29d8325cf0 100644 --- a/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationSuccessHandler.php +++ b/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationSuccessHandler.php @@ -100,7 +100,7 @@ protected function determineTargetUrl(Request $request): string } $firewallName = $this->getFirewallName(); - if (null !== $firewallName && $targetUrl = $this->getTargetPath($request->getSession(), $firewallName)) { + if (null !== $firewallName && !$request->attributes->getBoolean('_stateless') && $targetUrl = $this->getTargetPath($request->getSession(), $firewallName)) { $this->removeTargetPath($request->getSession(), $firewallName); return $targetUrl; diff --git a/src/Symfony/Component/Security/Http/Authenticator/AccessTokenAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/AccessTokenAuthenticator.php index ad08d30359518..20565b6c73d35 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/AccessTokenAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/AccessTokenAuthenticator.php @@ -59,7 +59,7 @@ public function authenticate(Request $request): Passport } $userBadge = $this->accessTokenHandler->getUserBadgeFrom($accessToken); - if ($this->userProvider) { + if ($this->userProvider && (null === $userBadge->getUserLoader() || $userBadge->getUserLoader() instanceof FallbackUserLoader)) { $userBadge->setUserLoader($this->userProvider->loadUserByIdentifier(...)); } diff --git a/src/Symfony/Component/Security/Http/Authenticator/Debug/TraceableAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/Debug/TraceableAuthenticator.php index b142ec510e6bc..ae43d53460fe7 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Debug/TraceableAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Debug/TraceableAuthenticator.php @@ -17,6 +17,7 @@ use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; use Symfony\Component\Security\Http\EntryPoint\Exception\NotAnEntryPointException; @@ -29,14 +30,13 @@ */ final class TraceableAuthenticator implements AuthenticatorInterface, InteractiveAuthenticatorInterface, AuthenticationEntryPointInterface { - private AuthenticatorInterface $authenticator; private ?Passport $passport = null; private ?float $duration = null; private ClassStub|string $stub; + private ?bool $authenticated = null; - public function __construct(AuthenticatorInterface $authenticator) + public function __construct(private AuthenticatorInterface $authenticator) { - $this->authenticator = $authenticator; } public function getInfo(): array @@ -46,6 +46,16 @@ public function getInfo(): array 'passport' => $this->passport, 'duration' => $this->duration, 'stub' => $this->stub ??= class_exists(ClassStub::class) ? new ClassStub($this->authenticator::class) : $this->authenticator::class, + 'authenticated' => $this->authenticated, + 'badges' => array_map( + static function (BadgeInterface $badge): array { + return [ + 'stub' => class_exists(ClassStub::class) ? new ClassStub($badge::class) : $badge::class, + 'resolved' => $badge->isResolved(), + ]; + }, + $this->passport?->getBadges() ?? [], + ), ]; } @@ -70,11 +80,15 @@ public function createToken(Passport $passport, string $firewallName): TokenInte public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { + $this->authenticated = true; + return $this->authenticator->onAuthenticationSuccess($request, $token, $firewallName); } public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { + $this->authenticated = false; + return $this->authenticator->onAuthenticationFailure($request, $exception); } diff --git a/src/Symfony/Component/Security/Http/Authenticator/Debug/TraceableAuthenticatorManagerListener.php b/src/Symfony/Component/Security/Http/Authenticator/Debug/TraceableAuthenticatorManagerListener.php index 2e933a32e8c49..218a38ef79c32 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Debug/TraceableAuthenticatorManagerListener.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Debug/TraceableAuthenticatorManagerListener.php @@ -53,6 +53,8 @@ public function authenticate(RequestEvent $event): void 'stub' => $this->hasVardumper ? new ClassStub($skippedAuthenticator::class) : $skippedAuthenticator::class, 'passport' => null, 'duration' => 0, + 'authenticated' => null, + 'badges' => [], ]; } diff --git a/src/Symfony/Component/Security/Http/Authenticator/FallbackUserLoader.php b/src/Symfony/Component/Security/Http/Authenticator/FallbackUserLoader.php new file mode 100644 index 0000000000000..65392781518ce --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/FallbackUserLoader.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authenticator; + +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * This wrapper serves as a marker interface to indicate badge user loaders that should not be overridden by the + * default user provider. + * + * @internal + */ +final class FallbackUserLoader +{ + public function __construct(private $inner) + { + } + + public function __invoke(mixed ...$args): ?UserInterface + { + return ($this->inner)(...$args); + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php index 116525ca8edf3..d1cb7f68c6448 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php @@ -131,6 +131,10 @@ private function getCredentials(Request $request): array $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $credentials['username']); + if (!\is_string($credentials['password']) && (!\is_object($credentials['password']) || !method_exists($credentials['password'], '__toString'))) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['password_parameter'], \gettype($credentials['password']))); + } + return $credentials; } diff --git a/src/Symfony/Component/Security/Http/Firewall/LogoutListener.php b/src/Symfony/Component/Security/Http/Firewall/LogoutListener.php index 885a0a1eb445b..77810524ef5c6 100644 --- a/src/Symfony/Component/Security/Http/Firewall/LogoutListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/LogoutListener.php @@ -66,7 +66,7 @@ public function supports(Request $request): ?bool * validate the request. * * @throws LogoutException if the CSRF token is invalid - * @throws \RuntimeException if the LogoutSuccessHandlerInterface instance does not return a response + * @throws \RuntimeException if the LogoutEvent listener does not set a response */ public function authenticate(RequestEvent $event): void { diff --git a/src/Symfony/Component/Security/Http/README.md b/src/Symfony/Component/Security/Http/README.md index df491eed73eab..65cbd2c06adcc 100644 --- a/src/Symfony/Component/Security/Http/README.md +++ b/src/Symfony/Component/Security/Http/README.md @@ -15,7 +15,7 @@ $ composer require symfony/security-http Sponsor ------- -The Security component for Symfony 6.3 is [backed][1] by [SymfonyCasts][2]. +The Security component for Symfony 6.4 is [backed][1] by [SymfonyCasts][2]. Learn Symfony faster by watching real projects being built and actively coding along with them. SymfonyCasts bridges that learning gap, bringing you video diff --git a/src/Symfony/Component/Security/Http/RateLimiter/DefaultLoginRateLimiter.php b/src/Symfony/Component/Security/Http/RateLimiter/DefaultLoginRateLimiter.php index 2db7ee144b98a..a32d4926abc15 100644 --- a/src/Symfony/Component/Security/Http/RateLimiter/DefaultLoginRateLimiter.php +++ b/src/Symfony/Component/Security/Http/RateLimiter/DefaultLoginRateLimiter.php @@ -28,11 +28,20 @@ final class DefaultLoginRateLimiter extends AbstractRequestRateLimiter { private RateLimiterFactory $globalFactory; private RateLimiterFactory $localFactory; + private string $secret; - public function __construct(RateLimiterFactory $globalFactory, RateLimiterFactory $localFactory) + /** + * @param non-empty-string $secret A secret to use for hashing the IP address and username + */ + public function __construct(RateLimiterFactory $globalFactory, RateLimiterFactory $localFactory, #[\SensitiveParameter] string $secret = '') { + if ('' === $secret) { + trigger_deprecation('symfony/security-http', '6.4', 'Calling "%s()" with an empty secret is deprecated. A non-empty secret will be mandatory in version 7.0.', __METHOD__); + // throw new \Symfony\Component\Security\Core\Exception\InvalidArgumentException('A non-empty secret is required.'); + } $this->globalFactory = $globalFactory; $this->localFactory = $localFactory; + $this->secret = $secret; } protected function getLimiters(Request $request): array @@ -41,8 +50,13 @@ protected function getLimiters(Request $request): array $username = preg_match('//u', $username) ? mb_strtolower($username, 'UTF-8') : strtolower($username); return [ - $this->globalFactory->create($request->getClientIp()), - $this->localFactory->create($username.'-'.$request->getClientIp()), + $this->globalFactory->create($this->hash($request->getClientIp())), + $this->localFactory->create($this->hash($username.'-'.$request->getClientIp())), ]; } + + private function hash(string $data): string + { + return strtr(substr(base64_encode(hash_hmac('sha256', $data, $this->secret, true)), 0, 8), '/+', '._'); + } } diff --git a/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php index 8c8d86284b59a..ccf11e49862b6 100644 --- a/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php @@ -21,6 +21,7 @@ use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\User\OidcUser; use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenHandler; +use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; /** @@ -61,7 +62,7 @@ public function testGetsUserIdentifierFromSignedToken(string $claim, string $exp ))->getUserBadgeFrom($token); $actualUser = $userBadge->getUserLoader()(); - $this->assertEquals(new UserBadge($expected, fn () => $expectedUser, $claims), $userBadge); + $this->assertEquals(new UserBadge($expected, new FallbackUserLoader(fn () => $expectedUser), $claims), $userBadge); $this->assertInstanceOf(OidcUser::class, $actualUser); $this->assertEquals($expectedUser, $actualUser); $this->assertEquals($claims, $userBadge->getAttributes()); diff --git a/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php index 3b96174a0d63e..2c8d9ae803f9d 100644 --- a/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php @@ -16,6 +16,7 @@ use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\User\OidcUser; use Symfony\Component\Security\Http\AccessToken\Oidc\OidcUserInfoTokenHandler; +use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -47,7 +48,7 @@ public function testGetsUserIdentifierFromOidcServerResponse(string $claim, stri $userBadge = (new OidcUserInfoTokenHandler($clientMock, null, $claim))->getUserBadgeFrom($accessToken); $actualUser = $userBadge->getUserLoader()(); - $this->assertEquals(new UserBadge($expected, fn () => $expectedUser, $claims), $userBadge); + $this->assertEquals(new UserBadge($expected, new FallbackUserLoader(fn () => $expectedUser), $claims), $userBadge); $this->assertInstanceOf(OidcUser::class, $actualUser); $this->assertEquals($expectedUser, $actualUser); $this->assertEquals($claims, $userBadge->getAttributes()); diff --git a/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationFailureHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationFailureHandlerTest.php index ea58ca3553b64..e29d62dc2ed24 100644 --- a/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationFailureHandlerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationFailureHandlerTest.php @@ -47,6 +47,7 @@ protected function setUp(): void $this->session = $this->createMock(SessionInterface::class); $this->request = $this->createMock(Request::class); + $this->request->attributes = new ParameterBag(['_stateless' => false]); $this->request->expects($this->any())->method('getSession')->willReturn($this->session); $this->exception = $this->getMockBuilder(AuthenticationException::class)->onlyMethods(['getMessage'])->getMock(); } @@ -90,6 +91,17 @@ public function testExceptionIsPersistedInSession() $handler->onAuthenticationFailure($this->request, $this->exception); } + public function testExceptionIsNotPersistedInSessionOnStatelessRequest() + { + $this->request->attributes = new ParameterBag(['_stateless' => true]); + + $this->session->expects($this->never()) + ->method('set')->with(SecurityRequestAttributes::AUTHENTICATION_ERROR, $this->exception); + + $handler = new DefaultAuthenticationFailureHandler($this->httpKernel, $this->httpUtils, [], $this->logger); + $handler->onAuthenticationFailure($this->request, $this->exception); + } + public function testExceptionIsPassedInRequestOnForward() { $options = ['failure_forward' => true]; diff --git a/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationSuccessHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationSuccessHandlerTest.php index 2d63821b42ccd..a9750223f0891 100644 --- a/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationSuccessHandlerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationSuccessHandlerTest.php @@ -56,6 +56,25 @@ public function testRequestRedirectionsWithTargetPathInSessions() $this->assertSame('http://localhost/admin/dashboard', $handler->onAuthenticationSuccess($requestWithSession, $token)->getTargetUrl()); } + public function testStatelessRequestRedirections() + { + $session = $this->createMock(SessionInterface::class); + $session->expects($this->never())->method('get')->with('_security.admin.target_path'); + $session->expects($this->never())->method('remove')->with('_security.admin.target_path'); + $statelessRequest = Request::create('/'); + $statelessRequest->setSession($session); + $statelessRequest->attributes->set('_stateless', true); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects($this->any())->method('generate')->willReturn('http://localhost/login'); + $httpUtils = new HttpUtils($urlGenerator); + $token = $this->createMock(TokenInterface::class); + $handler = new DefaultAuthenticationSuccessHandler($httpUtils); + $handler->setFirewallName('admin'); + + $this->assertSame('http://localhost/', $handler->onAuthenticationSuccess($statelessRequest, $token)->getTargetUrl()); + } + public static function getRequestRedirections() { return [ diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessTokenAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessTokenAuthenticatorTest.php new file mode 100644 index 0000000000000..4f010000429dd --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessTokenAuthenticatorTest.php @@ -0,0 +1,162 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Authenticator; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\InMemoryUser; +use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Http\AccessToken\AccessTokenExtractorInterface; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator; +use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; + +class AccessTokenAuthenticatorTest extends TestCase +{ + private AccessTokenHandlerInterface $accessTokenHandler; + private AccessTokenExtractorInterface $accessTokenExtractor; + private InMemoryUserProvider $userProvider; + + protected function setUp(): void + { + $this->accessTokenHandler = $this->createMock(AccessTokenHandlerInterface::class); + $this->accessTokenExtractor = $this->createMock(AccessTokenExtractorInterface::class); + $this->userProvider = new InMemoryUserProvider(['test' => ['password' => 's$cr$t']]); + } + + public function testAuthenticateWithoutAccessToken() + { + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('Invalid credentials.'); + + $request = Request::create('/test'); + + $this->accessTokenExtractor + ->expects($this->once()) + ->method('extractAccessToken') + ->with($request) + ->willReturn(null); + + $authenticator = new AccessTokenAuthenticator( + $this->accessTokenHandler, + $this->accessTokenExtractor, + ); + + $authenticator->authenticate($request); + } + + public function testAuthenticateWithoutProvider() + { + $request = Request::create('/test'); + + $this->accessTokenExtractor + ->expects($this->once()) + ->method('extractAccessToken') + ->with($request) + ->willReturn('test'); + $this->accessTokenHandler + ->expects($this->once()) + ->method('getUserBadgeFrom') + ->with('test') + ->willReturn(new UserBadge('john', fn () => new InMemoryUser('john', null))); + + $authenticator = new AccessTokenAuthenticator( + $this->accessTokenHandler, + $this->accessTokenExtractor, + $this->userProvider, + ); + + $passport = $authenticator->authenticate($request); + + $this->assertEquals('john', $passport->getUser()->getUserIdentifier()); + } + + public function testAuthenticateWithoutUserLoader() + { + $request = Request::create('/test'); + + $this->accessTokenExtractor + ->expects($this->once()) + ->method('extractAccessToken') + ->with($request) + ->willReturn('test'); + $this->accessTokenHandler + ->expects($this->once()) + ->method('getUserBadgeFrom') + ->with('test') + ->willReturn(new UserBadge('test')); + + $authenticator = new AccessTokenAuthenticator( + $this->accessTokenHandler, + $this->accessTokenExtractor, + $this->userProvider, + ); + + $passport = $authenticator->authenticate($request); + + $this->assertEquals('test', $passport->getUser()->getUserIdentifier()); + } + + public function testAuthenticateWithUserLoader() + { + $request = Request::create('/test'); + + $this->accessTokenExtractor + ->expects($this->once()) + ->method('extractAccessToken') + ->with($request) + ->willReturn('test'); + $this->accessTokenHandler + ->expects($this->once()) + ->method('getUserBadgeFrom') + ->with('test') + ->willReturn(new UserBadge('john', fn () => new InMemoryUser('john', null))); + + $authenticator = new AccessTokenAuthenticator( + $this->accessTokenHandler, + $this->accessTokenExtractor, + $this->userProvider, + ); + + $passport = $authenticator->authenticate($request); + + $this->assertEquals('john', $passport->getUser()->getUserIdentifier()); + } + + public function testAuthenticateWithFallbackUserLoader() + { + $request = Request::create('/test'); + + $this->accessTokenExtractor + ->expects($this->once()) + ->method('extractAccessToken') + ->with($request) + ->willReturn('test'); + $this->accessTokenHandler + ->expects($this->once()) + ->method('getUserBadgeFrom') + ->with('test') + ->willReturn(new UserBadge('test', new FallbackUserLoader(fn () => new InMemoryUser('john', null)))); + + $authenticator = new AccessTokenAuthenticator( + $this->accessTokenHandler, + $this->accessTokenExtractor, + $this->userProvider, + ); + + $passport = $authenticator->authenticate($request); + + $this->assertEquals('test', $passport->getUser()->getUserIdentifier()); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/Debug/TraceableAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/Debug/TraceableAuthenticatorTest.php index 8eac1ccf21489..67afed44aecfc 100644 --- a/src/Symfony/Component/Security/Http/Tests/Authenticator/Debug/TraceableAuthenticatorTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/Debug/TraceableAuthenticatorTest.php @@ -36,4 +36,14 @@ public function testGetInfo() $this->assertSame($passport, $traceable->authenticate($request)); $this->assertSame($passport, $traceable->getInfo()['passport']); } + + public function testGetInfoWithoutAuth() + { + $authenticator = $this->createMock(AuthenticatorInterface::class); + + $traceable = new TraceableAuthenticator($authenticator); + $this->assertNull($traceable->getInfo()['passport']); + $this->assertIsArray($traceable->getInfo()['badges']); + $this->assertSame([], $traceable->getInfo()['badges']); + } } diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/FormLoginAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/FormLoginAuthenticatorTest.php index 5499ca6682a91..b0b44d94ea73b 100644 --- a/src/Symfony/Component/Security/Http/Tests/Authenticator/FormLoginAuthenticatorTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/FormLoginAuthenticatorTest.php @@ -24,6 +24,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\HttpUtils; use Symfony\Component\Security\Http\Tests\Authenticator\Fixtures\PasswordUpgraderProvider; @@ -126,6 +127,44 @@ public function testHandleNonStringUsernameWithToString($postOnly) $this->authenticator->authenticate($request); } + /** + * @dataProvider postOnlyDataProvider + */ + public function testHandleNonStringPasswordWithArray(bool $postOnly) + { + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('The key "_password" must be a string, "array" given.'); + + $request = Request::create('/login_check', 'POST', ['_username' => 'foo', '_password' => []]); + $request->setSession($this->createSession()); + + $this->setUpAuthenticator(['post_only' => $postOnly]); + $this->authenticator->authenticate($request); + } + + /** + * @dataProvider postOnlyDataProvider + */ + public function testHandleNonStringPasswordWithToString(bool $postOnly) + { + $passwordObject = new class() { + public function __toString() + { + return 's$cr$t'; + } + }; + + $request = Request::create('/login_check', 'POST', ['_username' => 'foo', '_password' => $passwordObject]); + $request->setSession($this->createSession()); + + $this->setUpAuthenticator(['post_only' => $postOnly]); + $passport = $this->authenticator->authenticate($request); + + /** @var PasswordCredentials $credentialsBadge */ + $credentialsBadge = $passport->getBadge(PasswordCredentials::class); + $this->assertSame('s$cr$t', $credentialsBadge->getPassword()); + } + public static function postOnlyDataProvider() { yield [true]; diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php index 248a09efba64e..450d151398f9e 100644 --- a/src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php @@ -47,7 +47,7 @@ protected function setUp(): void 'limit' => 6, 'interval' => '1 minute', ], new InMemoryStorage()); - $limiter = new DefaultLoginRateLimiter($globalLimiter, $localLimiter); + $limiter = new DefaultLoginRateLimiter($globalLimiter, $localLimiter, '$3cre7'); $this->listener = new LoginThrottlingListener($this->requestStack, $limiter); } diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php index ba66a6e413581..628c3ea387b46 100644 --- a/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php @@ -175,10 +175,6 @@ public function eraseCredentials(): void { } - public function getUsername(): string - { - } - public function getUserIdentifier(): string { } diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php index 17650e6c0eddc..282dc2c4787a6 100644 --- a/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php @@ -552,25 +552,11 @@ public function setUser($user): void $this->user = $user; } - public function getUsername(): string - { - return $this->user->getUserIdentifier(); - } - public function getUserIdentifier(): string { return $this->getUserIdentifier(); } - public function isAuthenticated(): bool - { - return true; - } - - public function setAuthenticated(bool $isAuthenticated) - { - } - public function eraseCredentials(): void { } diff --git a/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php index 94c32ba8b342d..cde245637efea 100644 --- a/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php @@ -289,11 +289,6 @@ public function getSalt(): string return ''; } - public function getUsername(): string - { - return $this->username; - } - public function getUserIdentifier(): string { return $this->username; diff --git a/src/Symfony/Component/Serializer/Annotation/Context.php b/src/Symfony/Component/Serializer/Annotation/Context.php index 6995f24de2b69..3fc242c30e491 100644 --- a/src/Symfony/Component/Serializer/Annotation/Context.php +++ b/src/Symfony/Component/Serializer/Annotation/Context.php @@ -16,7 +16,7 @@ /** * @author Maxime Steinhausser */ -#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class Context { private array $groups; diff --git a/src/Symfony/Component/Serializer/Attribute/DeserializeFormatter.php b/src/Symfony/Component/Serializer/Attribute/DeserializeFormatter.php new file mode 100644 index 0000000000000..68d4c93b41893 --- /dev/null +++ b/src/Symfony/Component/Serializer/Attribute/DeserializeFormatter.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Attribute; + +/** + * Defines a callable that will be used to format the property data during deserialization. + * + * The first argument of that callable must be the input data. + * Then, it is possible to inject the {@see Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig} + * and services thanks to their FQCN. + * + * It must return the formatted data. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +#[\Attribute(\Attribute::TARGET_PROPERTY)] +final readonly class DeserializeFormatter +{ + /** + * @param callable $formatter + */ + public function __construct( + public mixed $formatter, + ) { + } +} diff --git a/src/Symfony/Component/Serializer/Attribute/Groups.php b/src/Symfony/Component/Serializer/Attribute/Groups.php new file mode 100644 index 0000000000000..4dc0e405c8d27 --- /dev/null +++ b/src/Symfony/Component/Serializer/Attribute/Groups.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Attribute; + +/** + * Defines groups that will be used to filter the property according + * to the groups given in the serialization/deserialization config. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +#[\Attribute(\Attribute::TARGET_PROPERTY)] +final readonly class Groups +{ + /** + * @var list + */ + public array $groups; + + /** + * @param non-empty-string|non-empty-array $groups + */ + public function __construct(string|array $groups) + { + $this->groups = array_values(array_unique((array) $groups)); + } +} diff --git a/src/Symfony/Component/Serializer/Attribute/MaxDepth.php b/src/Symfony/Component/Serializer/Attribute/MaxDepth.php new file mode 100644 index 0000000000000..35cac40af65f4 --- /dev/null +++ b/src/Symfony/Component/Serializer/Attribute/MaxDepth.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Attribute; + +/** + * Defines the maximum serialization depth for the property. + * + * When the maximum depth is reached, the $maxDepthReachedFormatter callable is called if it has been defined. + * + * The first argument of that callable must be the input data. + * Then, it is possible to inject the {@see Symfony\Component\Serializer\Serialize\Config\SerializeConfig} + * and services thanks to their FQCN. + * + * It must return the new data. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +#[\Attribute(\Attribute::TARGET_PROPERTY)] +final readonly class MaxDepth +{ + /** + * @param positive-int $maxDepth + * @param ?callable $maxDepthReachedFormatter + */ + public function __construct( + public int $maxDepth, + public mixed $maxDepthReachedFormatter = null, + ) { + } +} diff --git a/src/Symfony/Component/Serializer/Attribute/SerializeFormatter.php b/src/Symfony/Component/Serializer/Attribute/SerializeFormatter.php new file mode 100644 index 0000000000000..aeba409b85dcf --- /dev/null +++ b/src/Symfony/Component/Serializer/Attribute/SerializeFormatter.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Attribute; + +/** + * Defines a callable that will be used to format the property data during serialization. + * + * The first argument of that callable must be the input data. + * Then, it is possible to inject the {@see Symfony\Component\Serializer\Serialize\Config\SerializeConfig} + * and services thanks to their FQCN. + * + * It must return the formatted data. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +#[\Attribute(\Attribute::TARGET_PROPERTY)] +final readonly class SerializeFormatter +{ + /** + * @param callable $formatter + */ + public function __construct( + public mixed $formatter, + ) { + } +} diff --git a/src/Symfony/Component/Serializer/Attribute/SerializedName.php b/src/Symfony/Component/Serializer/Attribute/SerializedName.php new file mode 100644 index 0000000000000..ca995409c108f --- /dev/null +++ b/src/Symfony/Component/Serializer/Attribute/SerializedName.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Attribute; + +/** + * Defines the serialized property name. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +#[\Attribute(\Attribute::TARGET_PROPERTY)] +final readonly class SerializedName +{ + public function __construct( + public string $name, + ) { + } +} diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 0c3e6b617c1c2..f9a7bc4ebfcd5 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 7.0 --- + * Add an experimental revamped version of the component * Add method `getSupportedTypes()` to `DenormalizerInterface` and `NormalizerInterface` * Remove denormalization support for `AbstractUid` in `UidNormalizer`, use one of `AbstractUid` child class instead * Denormalizing to an abstract class in `UidNormalizer` now throws an `\Error` @@ -18,6 +19,7 @@ CHANGELOG 6.4 --- + * Allow `Context` attribute to target classes * Deprecate Doctrine annotations support in favor of native attributes * Deprecate passing an annotation reader to the constructor of `AnnotationLoader` * Allow the `Groups` attribute/annotation on classes diff --git a/src/Symfony/Component/Serializer/Config/CsvConfig.php b/src/Symfony/Component/Serializer/Config/CsvConfig.php new file mode 100644 index 0000000000000..bacdf59bf3dc3 --- /dev/null +++ b/src/Symfony/Component/Serializer/Config/CsvConfig.php @@ -0,0 +1,233 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Config; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * CSV format serialization/deserialization common configuration. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +abstract class CsvConfig +{ + protected string $delimiter = ','; + + protected string $enclosure = '"'; + + protected string $escapeChar = ''; + + protected string $keySeparator = '.'; + + /** + * @var list + */ + protected array $headers = []; + + protected bool $escapedFormulas = false; + + protected bool $asCollection = true; + + protected bool $noHeaders = false; + + protected string $endOfLine = "\n"; + + protected bool $utf8Bom = false; + + /** + * The column delimiter character. + */ + public function delimiter(): string + { + return $this->delimiter; + } + + /** + * @throws InvalidArgumentException + */ + public function withDelimiter(string $delimiter): static + { + if (1 !== \strlen($delimiter)) { + throw new InvalidArgumentException(sprintf('The "%s" delimiter must be a single character.', $delimiter)); + } + + $clone = clone $this; + $clone->delimiter = $delimiter; + + return $clone; + } + + /** + * The field enclosure character. + */ + public function enclosure(): string + { + return $this->enclosure; + } + + /** + * @throws InvalidArgumentException + */ + public function withEnclosure(string $enclosure): static + { + if (1 !== \strlen($enclosure)) { + throw new InvalidArgumentException(sprintf('The "%s" enclosure must be a single character.', $enclosure)); + } + + $clone = clone $this; + $clone->enclosure = $enclosure; + + return $clone; + } + + /** + * The escape character. + */ + public function escapeChar(): string + { + return $this->escapeChar; + } + + /** + * @throws InvalidArgumentException + */ + public function withEscapeChar(string $escapeChar): static + { + if (\strlen($escapeChar) > 1) { + throw new InvalidArgumentException(sprintf('The "%s" escape character must be empty or a single character.', $escapeChar)); + } + + $clone = clone $this; + $clone->escapeChar = $escapeChar; + + return $clone; + } + + /** + * The key separator when flattening arrays. + */ + public function keySeparator(): string + { + return $this->keySeparator; + } + + public function withKeySeparator(string $keySeparator): static + { + $clone = clone $this; + $clone->keySeparator = $keySeparator; + + return $clone; + } + + /** + * The CSV headers. + * + * @return list + */ + public function headers(): array + { + return $this->headers; + } + + /** + * @param list $headers + */ + public function withHeaders(array $headers): static + { + $clone = clone $this; + $clone->headers = $headers; + + return $clone; + } + + /** + * Whether formulas should be escaped. + */ + public function escapedFormulas(): bool + { + return $this->escapedFormulas; + } + + public function withEscapedFormulas(bool $escapedFormulas): static + { + $clone = clone $this; + $clone->escapedFormulas = $escapedFormulas; + + return $clone; + } + + /** + * Whether the decoded result should be considered as a collection or as a single element. + */ + public function asCollection(): bool + { + return $this->asCollection; + } + + public function withAsCollection(bool $asCollection): static + { + $clone = clone $this; + $clone->asCollection = $asCollection; + + return $clone; + } + + /** + * Whether the input (or output) is containing (or will contain) headers. + */ + public function noHeaders(): bool + { + return $this->noHeaders; + } + + public function withNoHeaders(bool $noHeaders): static + { + $clone = clone $this; + $clone->noHeaders = $noHeaders; + + return $clone; + } + + /** + * The end of line characters. + */ + public function endOfLine(): string + { + return $this->endOfLine; + } + + public function withEndOfLine(string $endOfLine): static + { + $clone = clone $this; + $clone->endOfLine = $endOfLine; + + return $clone; + } + + /** + * Whether to add the UTF-8 Byte Order Mark (BOM) at the beginning of the encoded result or not. + */ + public function utf8Bom(): bool + { + return $this->utf8Bom; + } + + public function withOutputUtf8Bom(bool $utf8Bom): static + { + $clone = clone $this; + $clone->utf8Bom = $utf8Bom; + + return $clone; + } +} diff --git a/src/Symfony/Component/Serializer/Config/JsonConfig.php b/src/Symfony/Component/Serializer/Config/JsonConfig.php new file mode 100644 index 0000000000000..0e92bc7c61e97 --- /dev/null +++ b/src/Symfony/Component/Serializer/Config/JsonConfig.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Config; + +/** + * JSON format serialization/deserialization common configuration. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +abstract class JsonConfig +{ + protected int $flags = 0; + + /** + * The flags bitmask. + * + * @see https://www.php.net/manual/en/json.constants.php + * + * @return positive-int + */ + public function flags(): int + { + return $this->flags; + } + + /** + * @param positive-int $flags + */ + public function withFlags(int $flags): static + { + $clone = clone $this; + $clone->flags = $flags; + + return $clone; + } +} diff --git a/src/Symfony/Component/Serializer/DependencyInjection/RuntimeSerializerServicesPass.php b/src/Symfony/Component/Serializer/DependencyInjection/RuntimeSerializerServicesPass.php new file mode 100644 index 0000000000000..aecab19756ac0 --- /dev/null +++ b/src/Symfony/Component/Serializer/DependencyInjection/RuntimeSerializerServicesPass.php @@ -0,0 +1,157 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\DependencyInjection; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\DependencyInjection\Attribute\Target; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\DependencyInjection\TypedReference; +use Symfony\Component\Serializer\Attribute\DeserializeFormatter; +use Symfony\Component\Serializer\Attribute\MaxDepth; +use Symfony\Component\Serializer\Attribute\SerializeFormatter; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\VarExporter\ProxyHelper; + +/** + * Creates and injects a service locator containing serializable classes formatter's needed services. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class RuntimeSerializerServicesPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('serializer.serializer')) { + return; + } + + $formatters = []; + foreach ($container->getDefinitions() as $definition) { + if (!$definition->hasTag('serializer.serializable')) { + continue; + } + + array_push($formatters, ...$this->formatters($definition->getClass())); + } + + $runtimeServices = []; + foreach ($formatters as $formatter) { + $formatterName = sprintf('%s::%s', $formatter->getClosureScopeClass()->getName(), $formatter->getName()); + foreach ($this->retrieveServices($container, $formatter) as $serviceName => $reference) { + $runtimeServices[sprintf('%s[%s]', $formatterName, $serviceName)] = $reference; + } + } + + $runtimeServicesLocator = ServiceLocatorTagPass::register($container, $runtimeServices); + + $container->getDefinition('serializer.serializer') + ->replaceArgument(1, $runtimeServicesLocator); + + $container->getDefinition('serializer.deserializer') + ->replaceArgument(1, $runtimeServicesLocator); + + $container->getDefinition('serializer.serialize.data_model_builder') + ->replaceArgument(1, $runtimeServicesLocator); + + $container->getDefinition('serializer.deserialize.data_model_builder') + ->replaceArgument(1, $runtimeServicesLocator); + } + + /** + * @param class-string $className + * + * @return list<\ReflectionFunction> + */ + private function formatters(string $className): array + { + $formatters = []; + foreach ((new \ReflectionClass($className))->getProperties() as $property) { + foreach ($property->getAttributes() as $attribute) { + if (!\in_array($attribute->getName(), [SerializeFormatter::class, DeserializeFormatter::class, MaxDepth::class])) { + continue; + } + + /** @var SerializeFormatter|DeserializeFormatter|MaxDepth $attributeInstance */ + $attributeInstance = $attribute->newInstance(); + + $formatter = $attributeInstance instanceof SerializeFormatter || $attributeInstance instanceof DeserializeFormatter + ? $attributeInstance->formatter + : $attributeInstance->maxDepthReachedFormatter; + + $formatters[] = new \ReflectionFunction(\Closure::fromCallable($formatter)); + } + } + + return $formatters; + } + + /** + * @return list + */ + private function retrieveServices(ContainerBuilder $container, \ReflectionFunction $function): array + { + $services = []; + + foreach ($function->getParameters() as $i => $parameter) { + // first argument is always the data itself + if (0 === $i) { + continue; + } + + $type = preg_replace('/(^|[(|&])\\\\/', '\1', ltrim(ProxyHelper::exportType($parameter) ?? '', '?')); + + $invalidBehavior = match (true) { + $parameter->isOptional() => ContainerInterface::IGNORE_ON_INVALID_REFERENCE, + $parameter->allowsNull() => ContainerInterface::NULL_ON_INVALID_REFERENCE, + default => ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE, + }; + + if ($autowireAttribute = ($parameter->getAttributes(Autowire::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)) { + $value = $autowireAttribute->newInstance()->value; + + if ($value instanceof Reference) { + $services[$parameter->name] = $type + ? new TypedReference($value, $type, $invalidBehavior, $parameter->name) + : new Reference($value, $invalidBehavior); + + continue; + } + + $services[$parameter->name] = new Reference('.value.'.$container->hash($value)); + $container->register((string) $services[$parameter->name], 'mixed') + ->setFactory('current') + ->addArgument([$value]); + + continue; + } + + if (is_a($type, SerializeConfig::class, allow_string: true) || is_a($type, DeserializeConfig::class, allow_string: true)) { + continue; + } + + if ('' === $type) { + continue; + } + + $services[$parameter->name] = new TypedReference($type, $type, $invalidBehavior, Target::parseName($parameter)); + } + + return $services; + } +} diff --git a/src/Symfony/Component/Serializer/DependencyInjection/SerializablePass.php b/src/Symfony/Component/Serializer/DependencyInjection/SerializablePass.php new file mode 100644 index 0000000000000..f359165bc0882 --- /dev/null +++ b/src/Symfony/Component/Serializer/DependencyInjection/SerializablePass.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\DependencyInjection; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Marks classes specified in the `serializer.serializable_paths` parameter globs as serializable. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class SerializablePass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('serializer.serializer')) { + return; + } + + foreach ($this->serializable($container->getParameter('serializer.serializable_paths')) as $className) { + $container->register($className, $className) + ->setAbstract(true) + ->addTag('container.excluded') + ->addTag('serializer.serializable'); + } + } + + /** + * @param list $globs + * + * @return iterable + */ + private function serializable(array $globs): iterable + { + $includedFiles = []; + + foreach ($globs as $glob) { + $paths = glob($glob, (\defined('GLOB_BRACE') ? \GLOB_BRACE : 0) | \GLOB_ONLYDIR | \GLOB_NOSORT); + + foreach ($paths as $path) { + if (!is_dir($path)) { + continue; + } + + $phpFiles = new \RegexIterator( + new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ), + '/^.+\.php$/i', + \RecursiveRegexIterator::GET_MATCH + ); + + foreach ($phpFiles as $file) { + $sourceFile = realpath($file[0]); + + try { + require_once $sourceFile; + } catch (\Throwable) { + continue; + } + + $includedFiles[$sourceFile] = true; + } + } + + foreach (get_declared_classes() as $class) { + $reflectionClass = new \ReflectionClass($class); + $sourceFile = $reflectionClass->getFileName(); + + if (!isset($includedFiles[$sourceFile])) { + continue; + } + + if ($reflectionClass->isAbstract() || $reflectionClass->isInterface() || $reflectionClass->isTrait()) { + continue; + } + + yield $reflectionClass->getName(); + } + } + } +} diff --git a/src/Symfony/Component/Serializer/DependencyInjection/SerializerPass.php b/src/Symfony/Component/Serializer/DependencyInjection/SerializerPass.php index 2a429054b0c7b..6950a6cdf435a 100644 --- a/src/Symfony/Component/Serializer/DependencyInjection/SerializerPass.php +++ b/src/Symfony/Component/Serializer/DependencyInjection/SerializerPass.php @@ -19,6 +19,7 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\Serializer\Debug\TraceableEncoder; use Symfony\Component\Serializer\Debug\TraceableNormalizer; +use Symfony\Component\Serializer\Serialize\SerializerInterface as ExperimentalSerializerInterface; /** * Adds all services with the tags "serializer.encoder" and "serializer.normalizer" as @@ -26,6 +27,7 @@ * * @author Javier Lopez * @author Robin Chalas + * @author Mathias Arlaud */ class SerializerPass implements CompilerPassInterface { @@ -70,5 +72,51 @@ public function process(ContainerBuilder $container): void $serializerDefinition = $container->getDefinition('serializer'); $serializerDefinition->replaceArgument(0, $normalizers); $serializerDefinition->replaceArgument(1, $encoders); + + if (!interface_exists(ExperimentalSerializerInterface::class)) { + return; + } + + // + // Experimental serializer + // + + $serializeTemplateGenerators = []; + foreach ($container->findTaggedServiceIds('serializer.serialize.template_generator') as $id => $tags) { + $tag = reset($tags); + $serializeTemplateGenerators[$tag['format']] = new Reference($id); + } + + $container->getDefinition('serializer.serialize.template') + ->replaceArgument(2, $serializeTemplateGenerators); + + $deserializeTemplateGenerators = []; + foreach ($container->findTaggedServiceIds('serializer.deserialize.template_generator.eager') as $id => $tags) { + $tag = reset($tags); + $deserializeTemplateGenerators[$tag['format']]['eager'] = new Reference($id); + } + + foreach ($container->findTaggedServiceIds('serializer.deserialize.template_generator.lazy') as $id => $tags) { + $tag = reset($tags); + $deserializeTemplateGenerators[$tag['format']]['lazy'] = new Reference($id); + } + + $container->getDefinition('serializer.deserialize.template') + ->replaceArgument(2, $deserializeTemplateGenerators); + + $serializable = []; + foreach ($container->getDefinitions() as $definition) { + if (!$definition->hasTag('serializer.serializable')) { + continue; + } + + $serializable[] = $definition->getClass(); + } + + $container->getDefinition('serializer.cache_warmer.template') + ->replaceArgument(0, $serializable); + + $container->getDefinition('serializer.cache_warmer.lazy_ghost') + ->replaceArgument(0, $serializable); } } diff --git a/src/Symfony/Component/Serializer/Deserialize/Config/CsvDeserializeConfig.php b/src/Symfony/Component/Serializer/Deserialize/Config/CsvDeserializeConfig.php new file mode 100644 index 0000000000000..b8a84ced56078 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Config/CsvDeserializeConfig.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Config; + +use Symfony\Component\Serializer\Config\CsvConfig; + +/** + * CSV format deserialization configuration. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +class CsvDeserializeConfig extends CsvConfig +{ +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Config/DeserializeConfig.php b/src/Symfony/Component/Serializer/Deserialize/Config/DeserializeConfig.php new file mode 100644 index 0000000000000..59c026bd0627c --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Config/DeserializeConfig.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Config; + +/** + * Deserialization base configuration. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +class DeserializeConfig +{ + /** + * @var list + */ + protected array $groups = []; + + protected bool $forceGenerateTemplate = false; + + protected ?bool $lazy = null; + + protected string $dateTimeFormat = \DateTimeInterface::RFC3339; + + protected JsonDeserializeConfig $jsonConfig; + + protected CsvDeserializeConfig $csvConfig; + + public function __construct() + { + $this->jsonConfig = new JsonDeserializeConfig(); + $this->csvConfig = new CsvDeserializeConfig(); + } + + /** + * @return list + */ + public function groups(): array + { + return $this->groups; + } + + /** + * @param non-empty-string|non-empty-array $groups + */ + public function withGroups(array|string $groups): static + { + $clone = clone $this; + $clone->groups = array_values(array_unique((array) $groups)); + + return $clone; + } + + public function forceGenerateTemplate(): bool + { + return $this->forceGenerateTemplate; + } + + public function withForceGenerateTemplate(bool $forceGenerateTemplate = true): static + { + $clone = clone $this; + $clone->forceGenerateTemplate = $forceGenerateTemplate; + + return $clone; + } + + public function lazy(): ?bool + { + return $this->lazy; + } + + public function withLazy(bool $lazy = true): static + { + $clone = clone $this; + $clone->lazy = $lazy; + + return $clone; + } + + public function dateTimeFormat(): string + { + return $this->dateTimeFormat; + } + + public function withDateTimeFormat(string $dateTimeFormat): static + { + $clone = clone $this; + $clone->dateTimeFormat = $dateTimeFormat; + + return $clone; + } + + public function json(): JsonDeserializeConfig + { + return $this->jsonConfig; + } + + public function withJsonConfig(JsonDeserializeConfig $config): static + { + $clone = clone $this; + $clone->jsonConfig = $config; + + return $clone; + } + + public function csv(): CsvDeserializeConfig + { + return $this->csvConfig; + } + + public function withCsvConfig(CsvDeserializeConfig $config): static + { + $clone = clone $this; + $clone->csvConfig = $config; + + return $clone; + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Config/JsonDeserializeConfig.php b/src/Symfony/Component/Serializer/Deserialize/Config/JsonDeserializeConfig.php new file mode 100644 index 0000000000000..01d70d5ccea0a --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Config/JsonDeserializeConfig.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Config; + +use Symfony\Component\Serializer\Config\JsonConfig; + +/** + * JSON format deserialization configuration. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +class JsonDeserializeConfig extends JsonConfig +{ +} diff --git a/src/Symfony/Component/Serializer/Deserialize/DataModel/CollectionNode.php b/src/Symfony/Component/Serializer/Deserialize/DataModel/CollectionNode.php new file mode 100644 index 0000000000000..8a301010316a6 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/DataModel/CollectionNode.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\DataModel; + +use Symfony\Component\Serializer\Type\Type; + +/** + * Represents a collection in the data model graph representation. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class CollectionNode implements DataModelNodeInterface +{ + public function __construct( + public Type $type, + public DataModelNodeInterface $item, + ) { + } + + public function identifier(): string + { + return (string) $this->type; + } + + public function type(): Type + { + return $this->type; + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/DataModel/DataModelBuilder.php b/src/Symfony/Component/Serializer/Deserialize/DataModel/DataModelBuilder.php new file mode 100644 index 0000000000000..133ca7ef20e39 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/DataModel/DataModelBuilder.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\DataModel; + +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Deserialize\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Php\ArgumentsNode; +use Symfony\Component\Serializer\Php\FunctionCallNode; +use Symfony\Component\Serializer\Php\MethodCallNode; +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Php\ScalarNode as PhpScalarNode; +use Symfony\Component\Serializer\Php\VariableNode; +use Symfony\Component\Serializer\Type\Type; +use Symfony\Component\VarExporter\ProxyHelper; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class DataModelBuilder implements DataModelBuilderInterface +{ + public function __construct( + private readonly PropertyMetadataLoaderInterface $propertyMetadataLoader, + private readonly ContainerInterface $runtimeServices, + ) { + } + + public function build(Type $type, DeserializeConfig $config, array $context = []): DataModelNodeInterface + { + if ($type->isObject() && $type->hasClass()) { + $typeString = (string) $type; + + if ($context['generated_classes'][$typeString] ??= false) { + return ObjectNode::ghost($type); + } + + $propertiesNodes = []; + $context['generated_classes'][$typeString] = true; + + foreach ($this->propertyMetadataLoader->load($type->className(), $config, ['original_type' => $type] + $context) as $serializedName => $propertyMetadata) { + $propertiesNodes[$serializedName] = [ + 'name' => $propertyMetadata->name(), + 'value' => $this->build($propertyMetadata->type(), $config, $context), + 'formatter' => function (PhpNodeInterface $provider) use ($propertyMetadata): PhpNodeInterface { + $formatter = $provider; + + foreach ($propertyMetadata->formatters() as $f) { + $reflection = new \ReflectionFunction(\Closure::fromCallable($f)); + $functionName = null === $reflection->getClosureScopeClass() + ? $reflection->getName() + : sprintf('%s::%s', $reflection->getClosureScopeClass()->getName(), $reflection->getName()); + + $arguments = []; + foreach ($reflection->getParameters() as $i => $parameter) { + if (0 === $i) { + $arguments[] = $formatter; + + continue; + } + + $parameterType = preg_replace('/^\\\\/', '\1', ltrim(ProxyHelper::exportType($parameter) ?? '', '?')); + if (is_a($parameterType, DeserializeConfig::class, allow_string: true)) { + $arguments[] = new VariableNode('config'); + + continue; + } + + $argumentName = sprintf('%s[%s]', $functionName, $parameter->name); + if ($this->runtimeServices->has($argumentName)) { + $arguments[] = new MethodCallNode(new VariableNode('services'), 'get', new ArgumentsNode([new PhpScalarNode($argumentName)])); + + continue; + } + + throw new LogicException(sprintf('Cannot resolve "%s" argument of "%s()".', $parameter->name, $functionName)); + } + + $formatter = new FunctionCallNode($functionName, new ArgumentsNode($arguments)); + } + + return $formatter; + }, + ]; + } + + return new ObjectNode($type, $propertiesNodes); + } + + if ($type->isCollection() && ($type->isList() || $type->isDict())) { + return new CollectionNode($type, $this->build($type->collectionValueType(), $config, $context)); + } + + return new ScalarNode($type); + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/DataModel/DataModelBuilderInterface.php b/src/Symfony/Component/Serializer/Deserialize/DataModel/DataModelBuilderInterface.php new file mode 100644 index 0000000000000..69cc178eb340c --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/DataModel/DataModelBuilderInterface.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\DataModel; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Type\Type; + +/** + * Creates a data model graph representation of a given type. + * + * This data model will be used by the {@see Symfony\Component\Serializer\Deserialize\Template\TemplateGeneratorInterface} + * to generate a PHP deserialization template. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface DataModelBuilderInterface +{ + /** + * @param array $context + */ + public function build(Type $type, DeserializeConfig $config, array $context = []): DataModelNodeInterface; +} diff --git a/src/Symfony/Component/Serializer/Deserialize/DataModel/DataModelNodeInterface.php b/src/Symfony/Component/Serializer/Deserialize/DataModel/DataModelNodeInterface.php new file mode 100644 index 0000000000000..837d36d263f54 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/DataModel/DataModelNodeInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\DataModel; + +use Symfony\Component\Serializer\Type\Type; + +/** + * Represents a node in the data model graph representation. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface DataModelNodeInterface +{ + public function identifier(): string; + + public function type(): Type; +} diff --git a/src/Symfony/Component/Serializer/Deserialize/DataModel/ObjectNode.php b/src/Symfony/Component/Serializer/Deserialize/DataModel/ObjectNode.php new file mode 100644 index 0000000000000..441786bf28a76 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/DataModel/ObjectNode.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\DataModel; + +use Symfony\Component\Serializer\Type\Type; + +/** + * Represents an object in the data model graph representation. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class ObjectNode implements DataModelNodeInterface +{ + /** + * @param array $properties + */ + public function __construct( + public Type $type, + public array $properties, + public bool $ghost = false, + ) { + } + + public static function ghost(Type $type): self + { + return new self($type, [], ghost: true); + } + + public function identifier(): string + { + return (string) $this->type; + } + + public function type(): Type + { + return $this->type; + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/DataModel/ScalarNode.php b/src/Symfony/Component/Serializer/Deserialize/DataModel/ScalarNode.php new file mode 100644 index 0000000000000..3c54fe60d871f --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/DataModel/ScalarNode.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\DataModel; + +use Symfony\Component\Serializer\Type\Type; + +/** + * Represents a scalar in the data model graph representation. + * + * Scalars are the leaves of the data model tree. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class ScalarNode implements DataModelNodeInterface +{ + public function __construct( + public Type $type, + ) { + } + + public function identifier(): string + { + return (string) $this->type; + } + + public function type(): Type + { + return $this->type; + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Decoder/CsvDecoder.php b/src/Symfony/Component/Serializer/Deserialize/Decoder/CsvDecoder.php new file mode 100644 index 0000000000000..fd30d39f22a21 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Decoder/CsvDecoder.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Decoder; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Encoder\CsvEncoder as LegacyCsvDecoder; +use Symfony\Component\Serializer\Exception\InvalidResourceException; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class CsvDecoder implements DecoderInterface +{ + private static LegacyCsvDecoder|null $legacyCsvDecoder = null; + + public static function decode(mixed $resource, int $offset, int $length, DeserializeConfig $config): mixed + { + if ('' === $content = @stream_get_contents($resource, $length, $offset)) { + throw new InvalidResourceException($resource); + } + + $csvConfig = $config->csv(); + + $legacyContext = [ + LegacyCsvDecoder::DELIMITER_KEY => $csvConfig->delimiter(), + LegacyCsvDecoder::ENCLOSURE_KEY => $csvConfig->enclosure(), + LegacyCsvDecoder::ESCAPE_CHAR_KEY => $csvConfig->escapeChar(), + LegacyCsvDecoder::END_OF_LINE => $csvConfig->endOfLine(), + LegacyCsvDecoder::ESCAPE_FORMULAS_KEY => $csvConfig->escapedFormulas(), + LegacyCsvDecoder::HEADERS_KEY => $csvConfig->headers(), + LegacyCsvDecoder::KEY_SEPARATOR_KEY => $csvConfig->keySeparator(), + LegacyCsvDecoder::NO_HEADERS_KEY => $csvConfig->noHeaders(), + LegacyCsvDecoder::AS_COLLECTION_KEY => $csvConfig->asCollection(), + LegacyCsvDecoder::OUTPUT_UTF8_BOM_KEY => $csvConfig->utf8Bom(), + ]; + + $legacyCsvDecoder = self::$legacyCsvDecoder ??= new LegacyCsvDecoder(); + + return $legacyCsvDecoder->decode($content, 'csv', $legacyContext); + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Decoder/DecoderInterface.php b/src/Symfony/Component/Serializer/Deserialize/Decoder/DecoderInterface.php new file mode 100644 index 0000000000000..71916d5ae4c61 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Decoder/DecoderInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Decoder; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Exception\InvalidResourceException; + +/** + * Decodes a subset of a given $resource stream. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface DecoderInterface +{ + /** + * @param resource $resource + * + * @throws InvalidResourceException + */ + public static function decode(mixed $resource, int $offset, int $length, DeserializeConfig $config): mixed; +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Decoder/JsonDecoder.php b/src/Symfony/Component/Serializer/Deserialize/Decoder/JsonDecoder.php new file mode 100644 index 0000000000000..8192d695faf59 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Decoder/JsonDecoder.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Decoder; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Exception\InvalidResourceException; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class JsonDecoder implements DecoderInterface +{ + public static function decode(mixed $resource, int $offset, int $length, DeserializeConfig $config): mixed + { + if ('' === $content = @stream_get_contents($resource, $length, $offset)) { + throw new InvalidResourceException($resource); + } + + try { + return json_decode($content, associative: true, flags: $config->json()->flags() | \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + throw new InvalidResourceException($resource); + } + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Deserializer.php b/src/Symfony/Component/Serializer/Deserialize/Deserializer.php new file mode 100644 index 0000000000000..a1e338b33eb7c --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Deserializer.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize; + +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface; +use Symfony\Component\Serializer\Deserialize\Template\Template; +use Symfony\Component\Serializer\Exception\RuntimeException; +use Symfony\Component\Serializer\Stream\MemoryStream; +use Symfony\Component\Serializer\Stream\StreamInterface; +use Symfony\Component\Serializer\Type\Type; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class Deserializer implements DeserializerInterface +{ + public function __construct( + private readonly Template $template, + private readonly ContainerInterface $runtimeServices, + private readonly InstantiatorInterface $instantiator, + private readonly string $templateCacheDir, + ) { + } + + public function deserialize(StreamInterface|string $input, Type $type, string $format, DeserializeConfig $config = null): mixed + { + if (\is_string($input)) { + $input = new MemoryStream($input); + } + + $config ??= new DeserializeConfig(); + $path = $this->template->path($type, $format, $config); + + if (!file_exists($path) || $config->forceGenerateTemplate()) { + $content = $this->template->content($type, $format, $config); + + if (!file_exists($this->templateCacheDir)) { + mkdir($this->templateCacheDir, recursive: true); + } + + $tmpFile = @tempnam(\dirname($path), basename($path)); + if (false === @file_put_contents($tmpFile, $content)) { + throw new RuntimeException(sprintf('Failed to write "%s" template file.', $path)); + } + + @rename($tmpFile, $path); + @chmod($path, 0666 & ~umask()); + } + + return (require $path)($input->resource(), $config, $this->instantiator, $this->runtimeServices); + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/DeserializerInterface.php b/src/Symfony/Component/Serializer/Deserialize/DeserializerInterface.php new file mode 100644 index 0000000000000..0d7221373bf78 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/DeserializerInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Stream\StreamInterface; +use Symfony\Component\Serializer\Type\Type; + +/** + * Deserializes an $input stream or string into a given $type for a specific $format and $config. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface DeserializerInterface +{ + public function deserialize(StreamInterface|string $input, Type $type, string $format, DeserializeConfig $config = null): mixed; +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Instantiator/EagerInstantiator.php b/src/Symfony/Component/Serializer/Deserialize/Instantiator/EagerInstantiator.php new file mode 100644 index 0000000000000..9b64fac977fba --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Instantiator/EagerInstantiator.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Instantiator; + +use Symfony\Component\Serializer\Exception\UnexpectedValueException; + +/** + * Instantiates a new $className eagerly, then set the given properties. + * + * The $className class must have a constructor without any parameter + * and the related properties must be public. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class EagerInstantiator implements InstantiatorInterface +{ + public function instantiate(string $className, array $properties): object + { + $object = new $className(); + + foreach ($properties as $name => $value) { + try { + $object->{$name} = $value(); + } catch (\TypeError|UnexpectedValueException $e) { + throw new UnexpectedValueException($e->getMessage(), previous: $e); + } + } + + return $object; + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Instantiator/InstantiatorInterface.php b/src/Symfony/Component/Serializer/Deserialize/Instantiator/InstantiatorInterface.php new file mode 100644 index 0000000000000..2016719866609 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Instantiator/InstantiatorInterface.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Instantiator; + +use Symfony\Component\Serializer\Exception\UnexpectedValueException; + +/** + * Instantiates a new $className object with the given $properties values. + * + * A property must be a callable that return the property value when being + * called to permit laziness when needed. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface InstantiatorInterface +{ + /** + * @template T of object + * + * @param class-string $className + * @param array $properties + * + * @return T + * + * @throws UnexpectedValueException + */ + public function instantiate(string $className, array $properties): object; +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Instantiator/LazyInstantiator.php b/src/Symfony/Component/Serializer/Deserialize/Instantiator/LazyInstantiator.php new file mode 100644 index 0000000000000..225269f2d7480 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Instantiator/LazyInstantiator.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Instantiator; + +use Symfony\Component\VarExporter\ProxyHelper; + +/** + * Instantiates a new $className lazy ghost {@see Symfony\Component\VarExporter\LazyGhostTrait}. + * + * The $className class must not final. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class LazyInstantiator implements InstantiatorInterface +{ + /** + * @var array{reflection: array>, lazy_class_name: array} + */ + private static array $cache = [ + 'reflection' => [], + 'lazy_class_name' => [], + ]; + + /** + * @var array + */ + private static array $lazyClassesLoaded = []; + + public function __construct( + private readonly string $cacheDir, + ) { + } + + public function instantiate(string $className, array $properties): object + { + $reflection = self::$cache['reflection'][$className] ??= new \ReflectionClass($className); + $lazyClassName = self::$cache['lazy_class_name'][$className] ??= sprintf('%sGhost', preg_replace('/\\\\/', '', $className)); + + if (isset(self::$lazyClassesLoaded[$className]) && class_exists($lazyClassName)) { + return $lazyClassName::createLazyGhost($properties); + } + + if (!file_exists($path = sprintf('%s%s%s.php', $this->cacheDir, \DIRECTORY_SEPARATOR, hash('xxh128', $className)))) { + if (!file_exists($this->cacheDir)) { + mkdir($this->cacheDir, recursive: true); + } + + $lazyClassName = sprintf('%sGhost', preg_replace('/\\\\/', '', $className)); + + file_put_contents($path, sprintf('class %s%s', $lazyClassName, ProxyHelper::generateLazyGhost($reflection))); + } + + eval(file_get_contents(sprintf('%s%s%s.php', $this->cacheDir, \DIRECTORY_SEPARATOR, hash('xxh128', $className)))); + + self::$lazyClassesLoaded[$className] = true; + + return $lazyClassName::createLazyGhost($properties); + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Mapping/AttributePropertyMetadataLoader.php b/src/Symfony/Component/Serializer/Deserialize/Mapping/AttributePropertyMetadataLoader.php new file mode 100644 index 0000000000000..e1493b99d1eb5 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Mapping/AttributePropertyMetadataLoader.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Mapping; + +use Symfony\Component\Serializer\Attribute\DeserializeFormatter; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Type\TypeExtractorInterface; + +/** + * Enhance properties deserialization metadata based on properties' attributes. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class AttributePropertyMetadataLoader implements PropertyMetadataLoaderInterface +{ + public function __construct( + private readonly PropertyMetadataLoaderInterface $decorated, + private readonly TypeExtractorInterface $typeExtractor, + ) { + } + + public function load(string $className, DeserializeConfig $config, array $context): array + { + $initialResult = $this->decorated->load($className, $config, $context); + $result = []; + + foreach ($initialResult as $initialSerializedName => $initialMetadata) { + $attributesMetadata = $this->propertyAttributesMetadata(new \ReflectionProperty($className, $initialMetadata->name())); + + if ([] !== $config->groups()) { + $matchingGroup = false; + foreach ($config->groups() as $group) { + if (isset($attributesMetadata['groups'][$group])) { + $matchingGroup = true; + + break; + } + } + + if (!$matchingGroup) { + continue; + } + } + + $serializedName = $attributesMetadata['name'] ?? $initialSerializedName; + + if (null !== $formatter = $attributesMetadata['formatter'] ?? null) { + $reflectionFormatter = new \ReflectionFunction(\Closure::fromCallable($formatter)); + $type = $this->typeExtractor->extractTypeFromParameter($reflectionFormatter->getParameters()[0]); + + $result[$serializedName] = $initialMetadata + ->withType($type) + ->withFormatter($formatter(...)); + + continue; + } + + $result[$serializedName] = $initialMetadata; + } + + return $result; + } + + /** + * @return array{groups?: array, name?: string, formatter?: callable} + */ + private function propertyAttributesMetadata(\ReflectionProperty $reflectionProperty): array + { + $metadata = []; + + $reflectionAttribute = $reflectionProperty->getAttributes(Groups::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; + if (null !== $reflectionAttribute) { + foreach ($reflectionAttribute->newInstance()->groups as $group) { + $metadata['groups'][$group] = true; + } + } + + $reflectionAttribute = $reflectionProperty->getAttributes(SerializedName::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; + if (null !== $reflectionAttribute) { + $metadata['name'] = $reflectionAttribute->newInstance()->name; + } + + $reflectionAttribute = $reflectionProperty->getAttributes(DeserializeFormatter::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; + if (null !== $reflectionAttribute) { + $metadata['formatter'] = $reflectionAttribute->newInstance()->formatter; + } + + return $metadata; + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Mapping/PropertyMetadata.php b/src/Symfony/Component/Serializer/Deserialize/Mapping/PropertyMetadata.php new file mode 100644 index 0000000000000..91e179dc0e161 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Mapping/PropertyMetadata.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Mapping; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Type\Type; + +/** + * Holds deserialization metadata about a given property. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class PropertyMetadata +{ + /** + * @param list $formatters + */ + public function __construct( + private string $name, + private Type $type, + private array $formatters = [], + ) { + self::validateFormatters($this); + } + + public function name(): string + { + return $this->name; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function type(): Type + { + return $this->type; + } + + public function withType(Type $type): self + { + $clone = clone $this; + $clone->type = $type; + + return $clone; + } + + /** + * @return list + */ + public function formatters(): array + { + return $this->formatters; + } + + /** + * @param list $formatters + */ + public function withFormatters(array $formatters): self + { + $clone = clone $this; + $clone->formatters = $formatters; + + self::validateFormatters($clone); + + return $clone; + } + + public function withFormatter(callable $formatter): self + { + $formatters = $this->formatters; + $formatters[] = $formatter; + + return $this->withFormatters($formatters); + } + + private static function validateFormatters(self $metadata): void + { + foreach ($metadata->formatters as $formatter) { + $reflection = new \ReflectionFunction(\Closure::fromCallable($formatter)); + + if ($reflection->getClosureScopeClass()?->hasMethod($reflection->getName())) { + if (!$reflection->isStatic()) { + throw new InvalidArgumentException(sprintf('"%s"\'s property deserialize formatter must be a static method.', $metadata->name)); + } + } else { + if ($reflection->isAnonymous()) { + throw new InvalidArgumentException(sprintf('"%s"\'s property deserialize formatter must not be anonymous.', $metadata->name)); + } + } + } + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Mapping/PropertyMetadataLoader.php b/src/Symfony/Component/Serializer/Deserialize/Mapping/PropertyMetadataLoader.php new file mode 100644 index 0000000000000..b98414110367b --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Mapping/PropertyMetadataLoader.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Mapping; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Type\TypeExtractorInterface; + +/** + * Loads basic properties deserialization metadata for a given $className. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class PropertyMetadataLoader implements PropertyMetadataLoaderInterface +{ + public function __construct( + private readonly TypeExtractorInterface $typeExtractor, + ) { + } + + public function load(string $className, DeserializeConfig $config, array $context): array + { + $result = []; + + foreach ((new \ReflectionClass($className))->getProperties() as $reflectionProperty) { + $name = $serializedName = $reflectionProperty->getName(); + $type = $this->typeExtractor->extractTypeFromProperty($reflectionProperty); + + $result[$serializedName] = new PropertyMetadata( + name: $name, + type: $type, + formatters: [], + ); + } + + return $result; + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Mapping/PropertyMetadataLoaderInterface.php b/src/Symfony/Component/Serializer/Deserialize/Mapping/PropertyMetadataLoaderInterface.php new file mode 100644 index 0000000000000..9531e09c44c0d --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Mapping/PropertyMetadataLoaderInterface.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Mapping; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; + +/** + * Loads properties deserialization metadata for a given $className. + * + * This metadata can be used by the {@see Symfony\Component\Serializer\Deserialize\DataModel\DataModelBuilderInterface} + * to create a more appropriate {@see Symfony\Component\Serializer\Deserialize\DataModel\ObjectNode}. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface PropertyMetadataLoaderInterface +{ + /** + * @param class-string $className + * @param array $context + * + * @return array + */ + public function load(string $className, DeserializeConfig $config, array $context): array; +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Mapping/TypePropertyMetadataLoader.php b/src/Symfony/Component/Serializer/Deserialize/Mapping/TypePropertyMetadataLoader.php new file mode 100644 index 0000000000000..970f503d509d5 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Mapping/TypePropertyMetadataLoader.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Mapping; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Type\Type; +use Symfony\Component\Serializer\Type\TypeExtractorInterface; +use Symfony\Component\Serializer\Type\TypeGenericsHelper; + +/** + * Enhance properties deserialization metadata based on properties' type. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class TypePropertyMetadataLoader implements PropertyMetadataLoaderInterface +{ + private readonly TypeGenericsHelper $typeGenericsHelper; + + public function __construct( + private readonly PropertyMetadataLoaderInterface $decorated, + TypeExtractorInterface $typeExtractor, + ) { + $this->typeGenericsHelper = new TypeGenericsHelper($typeExtractor); + } + + public function load(string $className, DeserializeConfig $config, array $context): array + { + $result = $this->decorated->load($className, $config, $context); + $genericTypes = $this->typeGenericsHelper->classGenericTypes($className, $context['original_type']); + + foreach ($result as &$metadata) { + $type = $metadata->type(); + + if (isset($genericTypes[(string) $type])) { + $metadata = $metadata->withType($this->typeGenericsHelper->replaceGenericTypes($type, $genericTypes)); + $type = $metadata->type(); + } + + if ($type->isObject() && $type->hasClass() && is_a($type->className(), \DateTimeInterface::class, true)) { + $metadata = $metadata + ->withType(Type::string()) + ->withFormatter(self::castStringToDateTime(...)); + } + } + + return $result; + } + + public static function castStringToDateTime(string $string, DeserializeConfig $config): \DateTimeInterface + { + if (false !== $dateTime = \DateTimeImmutable::createFromFormat($config->dateTimeFormat(), $string)) { + return $dateTime; + } + + return new \DateTimeImmutable($string); + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Splitter/JsonLexer.php b/src/Symfony/Component/Serializer/Deserialize/Splitter/JsonLexer.php new file mode 100644 index 0000000000000..c68e45cded345 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Splitter/JsonLexer.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Splitter; + +use Symfony\Component\Serializer\Exception\InvalidResourceException; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class JsonLexer +{ + private const MAX_CHUNK_LENGTH = 8192; + + private const WHITESPACE_CHARS = [' ' => true, "\r" => true, "\t" => true, "\n" => true]; + private const STRUCTURE_CHARS = [',' => true, ':' => true, '{' => true, '}' => true, '[' => true, ']' => true]; + + /** + * @param resource $resource + * + * @return \Iterator + * + * @throws InvalidResourceException + */ + public function tokens(mixed $resource, int $offset, int $length): \Iterator + { + $currentTokenPosition = $offset; + + $token = ''; + + $inString = $escaping = false; + $infiniteLength = -1 === $length; + $chunkLength = $infiniteLength ? self::MAX_CHUNK_LENGTH : min($length, self::MAX_CHUNK_LENGTH); + + if (false === @rewind($resource)) { + throw new InvalidResourceException($resource); + } + + $toReadLength = $length; + + while (!feof($resource) && ($infiniteLength || $toReadLength > 0)) { + if (!$buffer = @stream_get_contents($resource, $infiniteLength ? -1 : min($chunkLength, $toReadLength), $offset)) { + throw new InvalidResourceException($resource); + } + + $toReadLength -= $bufferLength = \strlen($buffer); + + for ($i = 0; $i < $bufferLength; ++$i) { + $byte = $buffer[$i]; + + if ($escaping) { + $escaping = false; + $token .= $byte; + + continue; + } + + if ($inString) { + $token .= $byte; + + if ('"' === $byte) { + $inString = false; + } elseif ('\\' === $byte) { + $escaping = true; + } + + continue; + } + + if ('"' === $byte) { + $token .= $byte; + $inString = true; + + continue; + } + + if (isset(self::STRUCTURE_CHARS[$byte]) || isset(self::WHITESPACE_CHARS[$byte])) { + if ('' !== $token) { + yield [$token, $currentTokenPosition]; + + $currentTokenPosition += \strlen($token); + $token = ''; + } + + if (!isset(self::WHITESPACE_CHARS[$byte])) { + yield [$byte, $currentTokenPosition]; + } + + if ('' !== $byte) { + ++$currentTokenPosition; + } + + continue; + } + + $token .= $byte; + } + + $offset += $bufferLength; + } + + if ('' !== $token) { + yield [$token, $currentTokenPosition]; + } + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Splitter/JsonSplitter.php b/src/Symfony/Component/Serializer/Deserialize/Splitter/JsonSplitter.php new file mode 100644 index 0000000000000..b01523f924696 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Splitter/JsonSplitter.php @@ -0,0 +1,183 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Splitter; + +use Symfony\Component\Serializer\Exception\InvalidResourceException; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class JsonSplitter implements SplitterInterface +{ + private const NESTING_CHARS = ['{' => true, '[' => true]; + private const UNNESTING_CHARS = ['}' => true, ']' => true]; + + private static JsonLexer|null $lexer = null; + + /** + * @var array{key: array} + */ + private static array $cache = [ + 'key' => [], + ]; + + public static function splitList(mixed $resource, int $offset = 0, int $length = -1): ?\Iterator + { + $lexer = self::$lexer ??= new JsonLexer(); + $tokens = $lexer->tokens($resource, $offset, $length); + + if ('null' === $tokens->current()[0] && 1 === iterator_count($tokens)) { + return null; + } + + return self::createListBoundaries($tokens, $resource); + } + + public static function splitDict(mixed $resource, int $offset = 0, int $length = -1): ?\Iterator + { + $lexer = self::$lexer ??= new JsonLexer(); + $tokens = $lexer->tokens($resource, $offset, $length); + + if ('null' === $tokens->current()[0] && 1 === iterator_count($tokens)) { + return null; + } + + return self::createDictBoundaries($tokens, $resource); + } + + /** + * @param \Iterator $tokens + * @param resource $resource + * + * @return \Iterator + */ + private static function createListBoundaries(\Iterator $tokens, mixed $resource): \Iterator + { + $level = 0; + + foreach ($tokens as $i => $token) { + if (0 === $i) { + continue; + } + + $value = $token[0]; + $position = $token[1]; + $offset = $offset ?? $position; + + if (isset(self::NESTING_CHARS[$value])) { + ++$level; + + continue; + } + + if (isset(self::UNNESTING_CHARS[$value])) { + --$level; + + continue; + } + + if (0 !== $level) { + continue; + } + + if (',' === $value) { + if (($length = $position - $offset) > 0) { + yield [$offset, $length]; + } + + $offset = null; + } + } + + if (-1 !== $level || !isset($value, $offset, $position) || ']' !== $value) { + throw new InvalidResourceException($resource); + } + + if (($length = $position - $offset) > 0) { + yield [$offset, $length]; + } + } + + /** + * @param \Iterator $tokens + * @param resource $resource + * + * @return \Iterator + */ + private static function createDictBoundaries(\Iterator $tokens, mixed $resource): \Iterator + { + $level = 0; + $offset = 0; + $firstValueToken = false; + $key = null; + + foreach ($tokens as $i => $token) { + if (0 === $i) { + continue; + } + + $value = $token[0]; + $position = $token[1]; + + if ($firstValueToken) { + $firstValueToken = false; + $offset = $position; + } + + if (isset(self::NESTING_CHARS[$value])) { + ++$level; + + continue; + } + + if (isset(self::UNNESTING_CHARS[$value])) { + --$level; + + continue; + } + + if (0 !== $level) { + continue; + } + + if (':' === $value) { + $firstValueToken = true; + + continue; + } + + if (',' === $value) { + if (null !== $key && ($length = $position - $offset) > 0) { + yield $key => [$offset, $length]; + } + + $key = null; + + continue; + } + + if (null === $key) { + $key = self::$cache['key'][$value] ??= json_decode($value); + } + } + + if (-1 !== $level || !isset($value, $position) || '}' !== $value) { + throw new InvalidResourceException($resource); + } + + if (null !== $key && ($length = $position - $offset) > 0) { + yield $key => [$offset, $length]; + } + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Splitter/SplitterInterface.php b/src/Symfony/Component/Serializer/Deserialize/Splitter/SplitterInterface.php new file mode 100644 index 0000000000000..083d1f18ed7e5 --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Splitter/SplitterInterface.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Splitter; + +/** + * Splits a list or a dictionnary in a subset of a given $resource stream and yields tokens. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface SplitterInterface +{ + /** + * @param resource $resource + * + * @return \Iterator|null + */ + public static function splitList(mixed $resource, int $offset = 0, int $length = -1): ?\Iterator; + + /** + * @param resource $resource + * + * @return \Iterator|null + */ + public static function splitDict(mixed $resource, int $offset = 0, int $length = -1): ?\Iterator; +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Template/EagerTemplateGenerator.php b/src/Symfony/Component/Serializer/Deserialize/Template/EagerTemplateGenerator.php new file mode 100644 index 0000000000000..19f43264aaf6c --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Template/EagerTemplateGenerator.php @@ -0,0 +1,269 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Template; + +use Symfony\Component\Serializer\Deserialize\DataModel\CollectionNode; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelNodeInterface; +use Symfony\Component\Serializer\Deserialize\DataModel\ObjectNode; +use Symfony\Component\Serializer\Deserialize\DataModel\ScalarNode; +use Symfony\Component\Serializer\Deserialize\Decoder\DecoderInterface; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Php\ArgumentsNode; +use Symfony\Component\Serializer\Php\ArrayAccessNode; +use Symfony\Component\Serializer\Php\ArrayNode; +use Symfony\Component\Serializer\Php\AssignNode; +use Symfony\Component\Serializer\Php\BinaryNode; +use Symfony\Component\Serializer\Php\CastNode; +use Symfony\Component\Serializer\Php\ClosureNode; +use Symfony\Component\Serializer\Php\ExpressionNode; +use Symfony\Component\Serializer\Php\ForEachNode; +use Symfony\Component\Serializer\Php\FunctionCallNode; +use Symfony\Component\Serializer\Php\IfNode; +use Symfony\Component\Serializer\Php\MethodCallNode; +use Symfony\Component\Serializer\Php\NewNode; +use Symfony\Component\Serializer\Php\ParametersNode; +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Php\ReturnNode; +use Symfony\Component\Serializer\Php\ScalarNode as PhpScalarNode; +use Symfony\Component\Serializer\Php\ThrowNode; +use Symfony\Component\Serializer\Php\TryCatchNode; +use Symfony\Component\Serializer\Php\VariableNode; +use Symfony\Component\Serializer\Php\YieldNode; + +/** + * Generates a template PHP syntax tree that deserializes data eagerly. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class EagerTemplateGenerator extends TemplateGenerator +{ + /** + * @param class-string $decoderClassName + */ + public function __construct( + private readonly string $decoderClassName, + ) { + } + + protected function returnDataNodes(DataModelNodeInterface $node, array &$context): array + { + return [ + new ExpressionNode(new ReturnNode(new FunctionCallNode( + new ArrayAccessNode(new VariableNode('providers'), new PhpScalarNode($node->identifier())), + new ArgumentsNode([ + new MethodCallNode(new PhpScalarNode('\\'.$this->decoderClassName), 'decode', new ArgumentsNode([ + new VariableNode('resource'), + new PhpScalarNode(0), + new PhpScalarNode(-1), + new VariableNode('config'), + ]), static: true), + ]), + ))), + ]; + } + + /** + * @param array $context + * + * @return list + */ + protected function collectionNodes(CollectionNode $node, array &$context): array + { + $returnNullNodes = $node->type->isNullable() ? [ + new IfNode(new BinaryNode('===', new PhpScalarNode(null), new VariableNode('data')), [ + new ExpressionNode(new ReturnNode(new PhpScalarNode(null))), + ]), + ] : []; + + $iterableClosureNodes = [ + new ExpressionNode(new AssignNode( + new VariableNode('iterable'), + new ClosureNode(new ParametersNode(['data' => 'iterable']), 'iterable', true, [ + new ForEachNode(new VariableNode('data'), new VariableNode('k'), new VariableNode('v'), [ + new ExpressionNode(new YieldNode( + new FunctionCallNode( + new ArrayAccessNode(new VariableNode('providers'), new PhpScalarNode($node->item->identifier())), + new ArgumentsNode([new VariableNode('v')]), + ), + new VariableNode('k'), + )), + ]), + ], new ArgumentsNode([ + new VariableNode('config'), + new VariableNode('instantiator'), + new VariableNode('providers', byReference: true), + ])), + )), + ]; + + $iterableValueNode = new FunctionCallNode(new VariableNode('iterable'), new ArgumentsNode([new VariableNode('data')])); + + $returnNodes = [ + new ExpressionNode(new ReturnNode( + 'array' === $node->type->name() ? new FunctionCallNode('\iterator_to_array', new ArgumentsNode([$iterableValueNode])) : $iterableValueNode, + )), + ]; + + return [ + new ExpressionNode(new AssignNode( + new ArrayAccessNode(new VariableNode('providers'), new PhpScalarNode($node->identifier())), + new ClosureNode( + new ParametersNode(['data' => '?iterable']), + ($node->type->isNullable() ? '?' : '').$node->type->name(), + true, + [...$returnNullNodes, ...$iterableClosureNodes, ...$returnNodes], + new ArgumentsNode([ + new VariableNode('config'), + new VariableNode('instantiator'), + new VariableNode('providers', byReference: true), + ]), + ), + )), + ...$this->providerNodes($node->item, $context), + ]; + } + + /** + * @param array $context + * + * @return list + */ + protected function objectNodes(ObjectNode $node, array &$context): array + { + $returnNullNodes = $node->type->isNullable() ? [ + new IfNode(new BinaryNode('===', new PhpScalarNode(null), new VariableNode('data')), [ + new ExpressionNode(new ReturnNode(new PhpScalarNode(null))), + ]), + ] : []; + + $propertyValueProvidersNodes = []; + $fillPropertiesArrayNodes = [ + new ExpressionNode(new AssignNode(new VariableNode('properties'), new ArrayNode([]))), + ]; + + foreach ($node->properties as $serializedName => $property) { + array_push($propertyValueProvidersNodes, ...$this->providerNodes($property['value'], $context)); + + $fillPropertiesArrayNodes[] = new IfNode(new FunctionCallNode( + 'isset', + new ArgumentsNode([new ArrayAccessNode(new VariableNode('data'), new PhpScalarNode($serializedName))]), + ), [ + new ExpressionNode(new AssignNode( + new ArrayAccessNode(new VariableNode('properties'), new PhpScalarNode($property['name'])), + new ClosureNode(new ParametersNode([]), 'mixed', true, [ + new ExpressionNode(new ReturnNode(($property['formatter'])(new FunctionCallNode( + new ArrayAccessNode(new VariableNode('providers'), new PhpScalarNode($property['value']->identifier())), + new ArgumentsNode([new ArrayAccessNode(new VariableNode('data'), new PhpScalarNode($serializedName))]), + )))), + ], new ArgumentsNode([ + new VariableNode('data'), + new VariableNode('config'), + new VariableNode('instantiator'), + new VariableNode('providers', byReference: true), + ])), + )), + ]); + } + + $instantiateNodes = [ + new ExpressionNode(new ReturnNode(new MethodCallNode( + new VariableNode('instantiator'), + 'instantiate', + new ArgumentsNode([new PhpScalarNode($node->type->className()), new VariableNode('properties')]), + ))), + ]; + + return [ + new ExpressionNode(new AssignNode( + new ArrayAccessNode(new VariableNode('providers'), new PhpScalarNode($node->identifier())), + new ClosureNode( + new ParametersNode(['data' => '?array']), + ($node->type->isNullable() ? '?' : '').$node->type->className(), + true, + [...$returnNullNodes, ...$fillPropertiesArrayNodes, ...$instantiateNodes], + new ArgumentsNode([ + new VariableNode('config'), + new VariableNode('instantiator'), + new VariableNode('providers', byReference: true), + ]), + ), + )), + ...$propertyValueProvidersNodes, + ]; + } + + /** + * @param array $context + * + * @return list + */ + protected function scalarNodes(ScalarNode $node, array &$context): array + { + $returnNullNodes = $node->type->isNullable() ? [ + new IfNode(new BinaryNode('===', new PhpScalarNode(null), new VariableNode('data')), [ + new ExpressionNode(new ReturnNode(new PhpScalarNode(null))), + ]), + ] : []; + + $formatDataNodes = match (true) { + \in_array($node->type->name(), ['int', 'string', 'float', 'bool', 'object', 'array'], true) => [ + new TryCatchNode([new ExpressionNode(new ReturnNode(new CastNode($node->type->name(), new VariableNode('data'))))], [ + new ExpressionNode(new ThrowNode(new NewNode('\\'.UnexpectedValueException::class, new ArgumentsNode([ + new FunctionCallNode('sprintf', new ArgumentsNode([ + new PhpScalarNode(sprintf('Cannot cast "%%s" to "%s"', $node->type->name())), + new FunctionCallNode('get_debug_type', new ArgumentsNode([new VariableNode('data')])), + ])), + ])))), + ], new ParametersNode(['e' => '\\Throwable'])), + ], + $node->type->isBackedEnum() => [ + new TryCatchNode([ + new ExpressionNode(new ReturnNode(new MethodCallNode( + new PhpScalarNode($node->type->className()), + 'from', + new ArgumentsNode([new VariableNode('data')]), + static: true, + ))), + ], [ + new ExpressionNode(new ThrowNode(new NewNode('\\'.UnexpectedValueException::class, new ArgumentsNode([ + new FunctionCallNode('sprintf', new ArgumentsNode([ + new PhpScalarNode(sprintf('Unexpected "%%s" value for "%s" backed enumeration.', $node->type)), + new VariableNode('data'), + ])), + ])))), + ], new ParametersNode(['e' => '\\ValueError'])), + ], + default => [ + new ExpressionNode(new ReturnNode(new VariableNode('data'))), + ], + }; + + return [ + new ExpressionNode(new AssignNode( + new ArrayAccessNode(new VariableNode('providers'), new PhpScalarNode($node->identifier())), + new ClosureNode( + new ParametersNode(['data' => 'mixed']), + 'mixed', + true, + [...$returnNullNodes, ...$formatDataNodes], + new ArgumentsNode([ + new VariableNode('config'), + new VariableNode('instantiator'), + new VariableNode('providers', byReference: true), + ]), + ), + )), + ]; + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Template/LazyTemplateGenerator.php b/src/Symfony/Component/Serializer/Deserialize/Template/LazyTemplateGenerator.php new file mode 100644 index 0000000000000..b160ec88037cf --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Template/LazyTemplateGenerator.php @@ -0,0 +1,309 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Template; + +use Symfony\Component\Serializer\Deserialize\DataModel\CollectionNode; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelNodeInterface; +use Symfony\Component\Serializer\Deserialize\DataModel\ObjectNode; +use Symfony\Component\Serializer\Deserialize\DataModel\ScalarNode; +use Symfony\Component\Serializer\Deserialize\Decoder\DecoderInterface; +use Symfony\Component\Serializer\Deserialize\Splitter\SplitterInterface; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Php\ArgumentsNode; +use Symfony\Component\Serializer\Php\ArrayAccessNode; +use Symfony\Component\Serializer\Php\ArrayNode; +use Symfony\Component\Serializer\Php\AssignNode; +use Symfony\Component\Serializer\Php\BinaryNode; +use Symfony\Component\Serializer\Php\CastNode; +use Symfony\Component\Serializer\Php\ClosureNode; +use Symfony\Component\Serializer\Php\ContinueNode; +use Symfony\Component\Serializer\Php\ExpressionNode; +use Symfony\Component\Serializer\Php\ForEachNode; +use Symfony\Component\Serializer\Php\FunctionCallNode; +use Symfony\Component\Serializer\Php\IfNode; +use Symfony\Component\Serializer\Php\MethodCallNode; +use Symfony\Component\Serializer\Php\NewNode; +use Symfony\Component\Serializer\Php\ParametersNode; +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Php\ReturnNode; +use Symfony\Component\Serializer\Php\ScalarNode as PhpScalarNode; +use Symfony\Component\Serializer\Php\ThrowNode; +use Symfony\Component\Serializer\Php\TryCatchNode; +use Symfony\Component\Serializer\Php\VariableNode; +use Symfony\Component\Serializer\Php\YieldNode; + +/** + * Generates a template PHP syntax tree that deserializes data lazily. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class LazyTemplateGenerator extends TemplateGenerator +{ + /** + * @param class-string $decoderClassName + * @param class-string $splitterClassName + */ + public function __construct( + private readonly string $decoderClassName, + private readonly string $splitterClassName, + ) { + } + + protected function returnDataNodes(DataModelNodeInterface $node, array &$context): array + { + return [ + new ExpressionNode(new ReturnNode(new FunctionCallNode( + new ArrayAccessNode(new VariableNode('providers'), new PhpScalarNode($node->identifier())), + new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode(0), new PhpScalarNode(-1)]), + ))), + ]; + } + + /** + * @param array $context + * + * @return list + */ + protected function collectionNodes(CollectionNode $node, array &$context): array + { + $getBoundariesNodes = [ + new ExpressionNode(new AssignNode(new VariableNode('boundaries'), new MethodCallNode( + new PhpScalarNode('\\'.$this->splitterClassName), + $node->type->isList() ? 'splitList' : 'splitDict', + new ArgumentsNode([new VariableNode('resource'), new VariableNode('offset'), new VariableNode('length')]), + static: true, + ))), + ]; + + if ($node->type->isNullable()) { + $getBoundariesNodes[] = new IfNode(new BinaryNode('===', new PhpScalarNode(null), new VariableNode('boundaries')), [ + new ExpressionNode(new ReturnNode(new PhpScalarNode(null))), + ]); + } + + $iterableClosureNodes = [ + new ExpressionNode(new AssignNode( + new VariableNode('iterable'), + new ClosureNode(new ParametersNode(['resource' => 'mixed', 'boundaries' => 'iterable']), 'iterable', true, [ + new ForEachNode(new VariableNode('boundaries'), new VariableNode('k'), new VariableNode('b'), [ + new ExpressionNode(new YieldNode( + new FunctionCallNode( + new ArrayAccessNode(new VariableNode('providers'), new PhpScalarNode($node->item->identifier())), + new ArgumentsNode([ + new VariableNode('resource'), + new ArrayAccessNode(new VariableNode('b'), new PhpScalarNode(0)), + new ArrayAccessNode(new VariableNode('b'), new PhpScalarNode(1)), + ]), + ), + new VariableNode('k'), + )), + ]), + ], new ArgumentsNode([ + new VariableNode('config'), + new VariableNode('instantiator'), + new VariableNode('providers', byReference: true), + ])), + )), + ]; + + $iterableValueNode = new FunctionCallNode( + new VariableNode('iterable'), + new ArgumentsNode([new VariableNode('resource'), new VariableNode('boundaries')]), + ); + + $returnNodes = [ + new ExpressionNode(new ReturnNode( + 'array' === $node->type->name() ? new FunctionCallNode('\iterator_to_array', new ArgumentsNode([$iterableValueNode])) : $iterableValueNode, + )), + ]; + + return [ + new ExpressionNode(new AssignNode( + new ArrayAccessNode(new VariableNode('providers'), new PhpScalarNode($node->identifier())), + new ClosureNode( + new ParametersNode(['resource' => 'mixed', 'offset' => 'int', 'length' => 'int']), + ($node->type->isNullable() ? '?' : '').$node->type->name(), + true, + [...$getBoundariesNodes, ...$iterableClosureNodes, ...$returnNodes], + new ArgumentsNode([ + new VariableNode('config'), + new VariableNode('instantiator'), + new VariableNode('providers', byReference: true), + ]), + ), + )), + ...$this->providerNodes($node->item, $context), + ]; + } + + /** + * @param array $context + * + * @return list + */ + protected function objectNodes(ObjectNode $node, array &$context): array + { + $getBoundariesNodes = [ + new ExpressionNode(new AssignNode(new VariableNode('boundaries'), new MethodCallNode( + new PhpScalarNode('\\'.$this->splitterClassName), + 'splitDict', + new ArgumentsNode([new VariableNode('resource'), new VariableNode('offset'), new VariableNode('length')]), + static: true, + ))), + ]; + + if ($node->type->isNullable()) { + $getBoundariesNodes[] = new IfNode(new BinaryNode('===', new PhpScalarNode(null), new VariableNode('boundaries')), [ + new ExpressionNode(new ReturnNode(new PhpScalarNode(null))), + ]); + } + + $propertyValueProvidersNodes = []; + $propertiesClosuresNodes = []; + + foreach ($node->properties as $serializedName => $property) { + array_push($propertyValueProvidersNodes, ...$this->providerNodes($property['value'], $context)); + + $propertiesClosuresNodes[] = new IfNode(new BinaryNode('===', new PhpScalarNode($serializedName), new VariableNode('k')), [ + new ExpressionNode(new AssignNode( + new ArrayAccessNode(new VariableNode('properties'), new PhpScalarNode($property['name'])), + new ClosureNode(new ParametersNode([]), 'mixed', true, [ + new ExpressionNode(new ReturnNode(($property['formatter'])(new FunctionCallNode( + new ArrayAccessNode(new VariableNode('providers'), new PhpScalarNode($property['value']->identifier())), + new ArgumentsNode([ + new VariableNode('resource'), + new ArrayAccessNode(new VariableNode('b'), new PhpScalarNode(0)), + new ArrayAccessNode(new VariableNode('b'), new PhpScalarNode(1)), + ]), + )))), + ], new ArgumentsNode([ + new VariableNode('resource'), + new VariableNode('b'), + new VariableNode('config'), + new VariableNode('instantiator'), + new VariableNode('providers', byReference: true), + ])), + )), + new ExpressionNode(new ContinueNode()), + ]); + } + + $fillPropertiesArrayNodes = [ + new ExpressionNode(new AssignNode(new VariableNode('properties'), new ArrayNode([]))), + new ForEachNode(new VariableNode('boundaries'), new VariableNode('k'), new VariableNode('b'), $propertiesClosuresNodes), + ]; + + $instantiateNodes = [ + new ExpressionNode(new ReturnNode(new MethodCallNode( + new VariableNode('instantiator'), + 'instantiate', + new ArgumentsNode([new PhpScalarNode($node->type->className()), new VariableNode('properties')]), + ))), + ]; + + return [ + new ExpressionNode(new AssignNode( + new ArrayAccessNode(new VariableNode('providers'), new PhpScalarNode($node->identifier())), + new ClosureNode( + new ParametersNode(['resource' => 'mixed', 'offset' => 'int', 'length' => 'int']), + ($node->type->isNullable() ? '?' : '').$node->type->className(), + true, + [...$getBoundariesNodes, ...$fillPropertiesArrayNodes, ...$instantiateNodes], + new ArgumentsNode([ + new VariableNode('config'), + new VariableNode('instantiator'), + new VariableNode('providers', byReference: true), + ]), + ), + )), + ...$propertyValueProvidersNodes, + ]; + } + + /** + * @param array $context + * + * @return list + */ + protected function scalarNodes(ScalarNode $node, array &$context): array + { + $getDataNodes = [ + new ExpressionNode(new AssignNode( + new VariableNode('data'), + new MethodCallNode(new PhpScalarNode('\\'.$this->decoderClassName), 'decode', new ArgumentsNode([ + new VariableNode('resource'), + new VariableNode('offset'), + new VariableNode('length'), + new VariableNode('config'), + ]), static: true), + )), + ]; + + if ($node->type->isNullable()) { + $getDataNodes[] = new IfNode(new BinaryNode('===', new PhpScalarNode(null), new VariableNode('data')), [ + new ExpressionNode(new ReturnNode(new PhpScalarNode(null))), + ]); + } + + $formatDataNodes = match (true) { + \in_array($node->type->name(), ['int', 'string', 'float', 'bool', 'object', 'array'], true) => [ + new TryCatchNode([new ExpressionNode(new ReturnNode(new CastNode($node->type->name(), new VariableNode('data'))))], [ + new ExpressionNode(new ThrowNode(new NewNode('\\'.UnexpectedValueException::class, new ArgumentsNode([ + new FunctionCallNode('sprintf', new ArgumentsNode([ + new PhpScalarNode(sprintf('Cannot cast "%%s" to "%s"', $node->type->name())), + new FunctionCallNode('get_debug_type', new ArgumentsNode([new VariableNode('data')])), + ])), + ])))), + ], new ParametersNode(['e' => '\\Throwable'])), + ], + $node->type->isEnum() => [ + new TryCatchNode([ + new ExpressionNode(new ReturnNode(new MethodCallNode( + new PhpScalarNode($node->type->className()), + 'from', + new ArgumentsNode([new VariableNode('data')]), + static: true, + ))), + ], [ + new ExpressionNode(new ThrowNode(new NewNode('\\'.UnexpectedValueException::class, new ArgumentsNode([ + new FunctionCallNode('sprintf', new ArgumentsNode([ + new PhpScalarNode(sprintf('Unexpected "%%s" value for "%s" backed enumeration.', $node->type)), + new VariableNode('data'), + ])), + ])))), + ], new ParametersNode(['e' => '\\ValueError'])), + ], + default => [ + new ExpressionNode(new ReturnNode(new VariableNode('data'))), + ], + }; + + return [ + new ExpressionNode(new AssignNode( + new ArrayAccessNode(new VariableNode('providers'), new PhpScalarNode($node->identifier())), + new ClosureNode( + new ParametersNode(['resource' => 'mixed', 'offset' => 'int', 'length' => 'int']), + 'mixed', + true, + [...$getDataNodes, ...$formatDataNodes], + new ArgumentsNode([ + new VariableNode('config'), + new VariableNode('instantiator'), + new VariableNode('providers', byReference: true), + ]), + ), + )), + ]; + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Template/Template.php b/src/Symfony/Component/Serializer/Deserialize/Template/Template.php new file mode 100644 index 0000000000000..bca433587f15f --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Template/Template.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Template; + +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelBuilderInterface; +use Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface; +use Symfony\Component\Serializer\Exception\UnsupportedFormatException; +use Symfony\Component\Serializer\Php\ClosureNode; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ExpressionNode; +use Symfony\Component\Serializer\Php\ParametersNode; +use Symfony\Component\Serializer\Php\PhpDocNode; +use Symfony\Component\Serializer\Php\ReturnNode; +use Symfony\Component\Serializer\Template\TemplateVariation; +use Symfony\Component\Serializer\Template\TemplateVariationExtractorInterface; +use Symfony\Component\Serializer\Type\Type; + +/** + * Provide path and contents of a deserialization template for a given type. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class Template +{ + /** + * @param array> $generators + */ + public function __construct( + private readonly TemplateVariationExtractorInterface $variationExtractor, + private readonly DataModelBuilderInterface $dataModelBuilder, + private readonly array $generators, + private readonly string $cacheDir, + private readonly bool $defaultLazy, + ) { + } + + public function path(Type $type, string $format, DeserializeConfig $config): string + { + $hash = hash('xxh128', (string) $type); + + $variations = $this->variationExtractor->extractVariationsFromConfig($config); + if ([] !== $variations) { + $hash .= '.'.hash('xxh128', implode('_', array_map(fn (TemplateVariation $t): string => (string) $t, $variations))); + } + + return sprintf( + '%s%s%s.deserialize.%s.%s.php', + $this->cacheDir, + \DIRECTORY_SEPARATOR, + $hash, + ($config->lazy() ?? $this->defaultLazy) ? 'lazy' : 'eager', + $format, + ); + } + + public function content(Type $type, string $format, DeserializeConfig $config): string + { + $lazy = $config->lazy() ?? $this->defaultLazy; + + $generator = $this->generators[$format][$lazy ? 'lazy' : 'eager'] ?? null; + if (null === $generator) { + throw new UnsupportedFormatException(sprintf('"%s" format is not supported %s.', $format, $lazy ? 'lazily' : 'eagerly')); + } + + $compiler = new Compiler(); + + $compiler->compile(new PhpDocNode([ + '@param resource $resource', + sprintf('@return %s', $type), + ])); + $phpDoc = $compiler->source(); + $compiler->reset(); + + $parametersNode = new ParametersNode([ + 'resource' => 'mixed', + 'config' => '\\'.DeserializeConfig::class, + 'instantiator' => '\\'.InstantiatorInterface::class, + 'services' => '\\'.ContainerInterface::class, + ]); + + $compiler->indent(); + $bodyNodes = $generator->generate($this->dataModelBuilder->build($type, $config), $config, []); + $compiler->outdent(); + + $compiler->compile(new ExpressionNode(new ReturnNode(new ClosureNode($parametersNode, 'mixed', true, $bodyNodes)))); + $php = $compiler->source(); + + return " + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Template; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Deserialize\DataModel\CollectionNode; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelNodeInterface; +use Symfony\Component\Serializer\Deserialize\DataModel\ObjectNode; +use Symfony\Component\Serializer\Deserialize\DataModel\ScalarNode; +use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Php\PhpNodeInterface; + +/** + * A base class to generate a deserialization template PHP syntax tree. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +abstract class TemplateGenerator implements TemplateGeneratorInterface +{ + /** + * @param array $context + * + * @return list + */ + abstract protected function returnDataNodes(DataModelNodeInterface $node, array &$context): array; + + /** + * @param array $context + * + * @return list + */ + abstract protected function collectionNodes(CollectionNode $node, array &$context): array; + + /** + * @param array $context + * + * @return list + */ + abstract protected function objectNodes(ObjectNode $node, array &$context): array; + + /** + * @param array $context + * + * @return list + */ + abstract protected function scalarNodes(ScalarNode $node, array &$context): array; + + final public function generate(DataModelNodeInterface $node, DeserializeConfig $config, array $context): array + { + return [ + ...$this->providerNodes($node, $context), + ...$this->returnDataNodes($node, $context), + ]; + } + + /** + * @param array $context + * + * @return list + */ + final protected function providerNodes(DataModelNodeInterface $node, array &$context): array + { + if ($context['providers'][$node->identifier()] ?? false) { + return []; + } + + if ($node instanceof ObjectNode && $node->ghost) { + return []; + } + + $context['providers'][$node->identifier()] = true; + + return match (true) { + $node instanceof CollectionNode => $this->collectionNodes($node, $context), + $node instanceof ObjectNode => $this->objectNodes($node, $context), + $node instanceof ScalarNode => $this->scalarNodes($node, $context), + default => throw new LogicException(sprintf('Unexpected "%s" node', $node::class)), + }; + } +} diff --git a/src/Symfony/Component/Serializer/Deserialize/Template/TemplateGeneratorInterface.php b/src/Symfony/Component/Serializer/Deserialize/Template/TemplateGeneratorInterface.php new file mode 100644 index 0000000000000..3a16eedfd742e --- /dev/null +++ b/src/Symfony/Component/Serializer/Deserialize/Template/TemplateGeneratorInterface.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Deserialize\Template; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelNodeInterface; +use Symfony\Component\Serializer\Php\PhpNodeInterface; + +/** + * Generates a deserialization template PHP syntax tree based on a given data model. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface TemplateGeneratorInterface +{ + /** + * @param array $context + * + * @return list + */ + public function generate(DataModelNodeInterface $node, DeserializeConfig $config, array $context): array; +} diff --git a/src/Symfony/Component/Serializer/Exception/InvalidResourceException.php b/src/Symfony/Component/Serializer/Exception/InvalidResourceException.php new file mode 100644 index 0000000000000..8b18a2cf1560d --- /dev/null +++ b/src/Symfony/Component/Serializer/Exception/InvalidResourceException.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Exception; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class InvalidResourceException extends UnexpectedValueException +{ + /** + * @param resource $resource + */ + public function __construct(mixed $resource) + { + parent::__construct(sprintf('Resource "%s" is not valid.', stream_get_meta_data($resource)['uri'])); + } +} diff --git a/src/Symfony/Component/Serializer/Exception/MaxDepthException.php b/src/Symfony/Component/Serializer/Exception/MaxDepthException.php new file mode 100644 index 0000000000000..879d9d4d72668 --- /dev/null +++ b/src/Symfony/Component/Serializer/Exception/MaxDepthException.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Exception; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class MaxDepthException extends RuntimeException +{ + /** + * @param class-string $className + */ + public function __construct(string $className, int $limit) + { + parent::__construct(sprintf('Max depth has been reached for class "%s" (configured limit: %d).', $className, $limit)); + } +} diff --git a/src/Symfony/Component/Serializer/Mapping/ClassDiscriminatorFromClassMetadata.php b/src/Symfony/Component/Serializer/Mapping/ClassDiscriminatorFromClassMetadata.php index d6f4bbe67f896..4677e19d5b5f3 100644 --- a/src/Symfony/Component/Serializer/Mapping/ClassDiscriminatorFromClassMetadata.php +++ b/src/Symfony/Component/Serializer/Mapping/ClassDiscriminatorFromClassMetadata.php @@ -65,7 +65,9 @@ private function resolveMappingForMappedObject(object|string $object): ?ClassDis { $reflectionClass = new \ReflectionClass($object); if ($parentClass = $reflectionClass->getParentClass()) { - return $this->getMappingForMappedObject($parentClass->getName()); + if (null !== ($parentMapping = $this->getMappingForMappedObject($parentClass->getName()))) { + return $parentMapping; + } } foreach ($reflectionClass->getInterfaceNames() as $interfaceName) { diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php index 53f203f034922..a92cd2a4d72dd 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php +++ b/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php @@ -48,6 +48,7 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool $className = $reflectionClass->name; $loaded = false; $classGroups = []; + $classContextAnnotation = null; $attributesMetadata = $classMetadata->getAttributesMetadata(); @@ -62,6 +63,12 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool if ($annotation instanceof Groups) { $classGroups = $annotation->getGroups(); + + continue; + } + + if ($annotation instanceof Context) { + $classContextAnnotation = $annotation; } } @@ -72,6 +79,10 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool } if ($property->getDeclaringClass()->name === $className) { + if ($classContextAnnotation) { + $this->setAttributeContextsForGroups($classContextAnnotation, $attributesMetadata[$property->name]); + } + foreach ($classGroups as $group) { $attributesMetadata[$property->name]->addGroup($group); } diff --git a/src/Symfony/Component/Serializer/NameConverter/MetadataAwareNameConverter.php b/src/Symfony/Component/Serializer/NameConverter/MetadataAwareNameConverter.php index 0c97e227c47b0..d1a0b28f29339 100644 --- a/src/Symfony/Component/Serializer/NameConverter/MetadataAwareNameConverter.php +++ b/src/Symfony/Component/Serializer/NameConverter/MetadataAwareNameConverter.php @@ -127,11 +127,14 @@ private function getCacheValueForAttributesMetadata(string $class, array $contex throw new LogicException(sprintf('Found SerializedName and SerializedPath annotations on property "%s" of class "%s".', $name, $class)); } - $groups = $metadata->getGroups(); - if (!$groups && ($context[AbstractNormalizer::GROUPS] ?? [])) { + $metadataGroups = $metadata->getGroups(); + $contextGroups = (array) ($context[AbstractNormalizer::GROUPS] ?? []); + + if ($contextGroups && !$metadataGroups) { continue; } - if ($groups && !array_intersect($groups, (array) ($context[AbstractNormalizer::GROUPS] ?? []))) { + + if ($metadataGroups && !array_intersect($metadataGroups, $contextGroups) && !\in_array('*', $contextGroups, true)) { continue; } diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index ddca68f585e9e..ae00256e1a705 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Serializer\Normalizer; -use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException; +use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException as PropertyAccessInvalidArgumentException; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; @@ -21,6 +21,7 @@ use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Encoder\XmlEncoder; use Symfony\Component\Serializer\Exception\ExtraAttributesException; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -302,13 +303,15 @@ public function denormalize(mixed $data, string $type, string $format = null, ar $mappedClass = $this->getMappedClass($normalizedData, $type, $context); $nestedAttributes = $this->getNestedAttributes($mappedClass); - $nestedData = []; + $nestedData = $originalNestedData = []; $propertyAccessor = PropertyAccess::createPropertyAccessor(); foreach ($nestedAttributes as $property => $serializedPath) { if (null === $value = $propertyAccessor->getValue($normalizedData, $serializedPath)) { continue; } - $nestedData[$property] = $value; + $convertedProperty = $this->nameConverter ? $this->nameConverter->normalize($property, $mappedClass, $format, $context) : $property; + $nestedData[$convertedProperty] = $value; + $originalNestedData[$property] = $value; $normalizedData = $this->removeNestedValue($serializedPath->getElements(), $normalizedData); } @@ -321,7 +324,7 @@ public function denormalize(mixed $data, string $type, string $format = null, ar if ($this->nameConverter) { $notConverted = $attribute; $attribute = $this->nameConverter->denormalize($attribute, $resolvedClass, $format, $context); - if (isset($nestedData[$notConverted]) && !isset($nestedData[$attribute])) { + if (isset($nestedData[$notConverted]) && !isset($originalNestedData[$attribute])) { throw new LogicException(sprintf('Duplicate values for key "%s" found. One value is set via the SerializedPath annotation: "%s", the other one is set via the SerializedName annotation: "%s".', $notConverted, implode('->', $nestedAttributes[$notConverted]->getElements()), $attribute)); } } @@ -361,7 +364,7 @@ public function denormalize(mixed $data, string $type, string $format = null, ar try { $this->setAttributeValue($object, $attribute, $value, $format, $attributeContext); - } catch (InvalidArgumentException $e) { + } catch (PropertyAccessInvalidArgumentException $e) { $exception = NotNormalizableValueException::createForUnexpectedDataType( sprintf('Failed to denormalize attribute "%s" value for class "%s": '.$e->getMessage(), $attribute, $type), $data, @@ -533,7 +536,7 @@ private function validateAndDenormalize(array $types, string $currentClass, stri if (('is_'.$builtinType)($data)) { return $data; } - } catch (NotNormalizableValueException $e) { + } catch (NotNormalizableValueException|InvalidArgumentException $e) { if (!$isUnionType) { throw $e; } diff --git a/src/Symfony/Component/Serializer/Php/ArgumentsNode.php b/src/Symfony/Component/Serializer/Php/ArgumentsNode.php new file mode 100644 index 0000000000000..502d440579ff0 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/ArgumentsNode.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class ArgumentsNode implements PhpNodeInterface +{ + /** + * @param array $arguments + */ + public function __construct( + public array $arguments, + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler->raw(implode(', ', array_map(fn (PhpNodeInterface $n): string => $compiler->subcompile($n), $this->arguments))); + } + + public function optimize(Optimizer $optimizer): static + { + return new self($optimizer->optimize($this->arguments)); + } +} diff --git a/src/Symfony/Component/Serializer/Php/ArrayAccessNode.php b/src/Symfony/Component/Serializer/Php/ArrayAccessNode.php new file mode 100644 index 0000000000000..8c8b0febbd7c9 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/ArrayAccessNode.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class ArrayAccessNode implements PhpNodeInterface +{ + public function __construct( + public PhpNodeInterface $array, + public ?PhpNodeInterface $key, + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler->raw(sprintf( + '%s[%s]', + $compiler->subcompile($this->array), + null !== $this->key ? $compiler->subcompile($this->key) : '', + )); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $optimizer->optimize($this->array), + null !== $this->key ? $optimizer->optimize($this->key) : null, + ); + } +} diff --git a/src/Symfony/Component/Serializer/Php/ArrayNode.php b/src/Symfony/Component/Serializer/Php/ArrayNode.php new file mode 100644 index 0000000000000..d22ec895d5840 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/ArrayNode.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class ArrayNode implements PhpNodeInterface +{ + /** + * @param array $elements + */ + public function __construct( + public array $elements, + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler->raw('['); + + $first = true; + $associative = !array_is_list($this->elements); + + foreach ($this->elements as $key => $value) { + if (!$first) { + $compiler->raw(', '); + } + + $first = false; + + if ($associative) { + $compiler->compile(new ScalarNode($key))->raw(' => '); + } + + $compiler->compile($value); + } + + $compiler->raw(']'); + } + + public function optimize(Optimizer $optimizer): static + { + return new self($optimizer->optimize($this->elements)); + } +} diff --git a/src/Symfony/Component/Serializer/Php/AssignNode.php b/src/Symfony/Component/Serializer/Php/AssignNode.php new file mode 100644 index 0000000000000..80fc48aefb414 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/AssignNode.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class AssignNode implements PhpNodeInterface +{ + public function __construct( + public PhpNodeInterface $left, + public PhpNodeInterface $right, + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler + ->compile($this->left) + ->raw(' = ') + ->compile($this->right); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $optimizer->optimize($this->left), + $optimizer->optimize($this->right), + ); + } +} diff --git a/src/Symfony/Component/Serializer/Php/BinaryNode.php b/src/Symfony/Component/Serializer/Php/BinaryNode.php new file mode 100644 index 0000000000000..f5b47d7e3c81b --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/BinaryNode.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class BinaryNode implements PhpNodeInterface +{ + private const OPERATORS = [ + '&&', + '||', + '===', + '!==', + 'instanceof', + '??', + '+', + '-', + ]; + + public function __construct( + public string $operator, + public PhpNodeInterface $left, + public PhpNodeInterface $right, + ) { + if (!\in_array($this->operator, self::OPERATORS)) { + throw new InvalidArgumentException(sprintf('Invalid "%s" operator.', $this->operator)); + } + } + + public function compile(Compiler $compiler): void + { + $compiler + ->compile($this->left) + ->raw(' '.$this->operator.' ') + ->compile($this->right); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $this->operator, + $optimizer->optimize($this->left), + $optimizer->optimize($this->right), + ); + } +} diff --git a/src/Symfony/Component/Serializer/Php/CastNode.php b/src/Symfony/Component/Serializer/Php/CastNode.php new file mode 100644 index 0000000000000..73230091c4a35 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/CastNode.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class CastNode implements PhpNodeInterface +{ + public function __construct( + public string $type, + public PhpNodeInterface $node, + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler->raw(sprintf('(%s) (%s)', $this->type, $compiler->subcompile($this->node))); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $this->type, + $optimizer->optimize($this->node), + ); + } +} diff --git a/src/Symfony/Component/Serializer/Php/ClosureNode.php b/src/Symfony/Component/Serializer/Php/ClosureNode.php new file mode 100644 index 0000000000000..f3d50e7eb14cf --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/ClosureNode.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class ClosureNode implements PhpNodeInterface +{ + /** + * @param list $body + */ + public function __construct( + public ParametersNode $parameters, + public ?string $returnType, + public bool $static, + public array $body, + public ?ArgumentsNode $uses = null, + ) { + } + + public function compile(Compiler $compiler): void + { + $staticSource = $this->static ? 'static ' : ''; + $parametersSource = $compiler->subcompile($this->parameters); + $usesSource = null !== $this->uses ? sprintf(' use (%s)', $compiler->subcompile($this->uses)) : ''; + $returnTypeSource = $this->returnType ? ': '.$this->returnType : ''; + + $compiler + ->raw(sprintf('%sfunction (%s)%s%s {', $staticSource, $parametersSource, $usesSource, $returnTypeSource)."\n") + ->indent(); + + foreach ($this->body as $bodyNode) { + $compiler->compile($bodyNode); + } + + $compiler + ->outdent() + ->raw('}', indent: true); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $optimizer->optimize($this->parameters), + $this->returnType, + $this->static, + $optimizer->optimize($this->body), + null !== $this->uses ? $optimizer->optimize($this->uses) : null, + ); + } +} diff --git a/src/Symfony/Component/Serializer/Php/Compiler.php b/src/Symfony/Component/Serializer/Php/Compiler.php new file mode 100644 index 0000000000000..bef2da806c62b --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/Compiler.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * Compiles a PHP syntax tree to actual PHP code. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class Compiler +{ + private readonly Optimizer $optimizer; + private string $source = ''; + private int $indentationLevel = 0; + + public function __construct() + { + $this->optimizer = new Optimizer(); + } + + public function reset(): static + { + $this->source = ''; + $this->indentationLevel = 0; + + return $this; + } + + public function source(): string + { + return $this->source; + } + + public function compile(PhpNodeInterface $node): static + { + $this->optimizer->optimize($node)->compile($this); + + return $this; + } + + public function subcompile(PhpNodeInterface $node): string + { + $mainSource = $this->source; + $this->source = ''; + + $node->compile($this); + $subCompiledSource = $this->source; + + $this->source = $mainSource; + + return $subCompiledSource; + } + + public function raw(string $string, bool $indent = false): static + { + $prefix = $indent ? str_repeat(' ', 4 * $this->indentationLevel) : ''; + + $this->source .= $prefix.$string; + + return $this; + } + + public function line(string $line): static + { + return $this->raw($line."\n", true); + } + + public function indent(): static + { + ++$this->indentationLevel; + + return $this; + } + + public function outdent(): static + { + $this->indentationLevel = max(0, $this->indentationLevel - 1); + + return $this; + } +} diff --git a/src/Symfony/Component/Serializer/Php/ContinueNode.php b/src/Symfony/Component/Serializer/Php/ContinueNode.php new file mode 100644 index 0000000000000..78dd1e107f758 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/ContinueNode.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class ContinueNode implements PhpNodeInterface +{ + public function compile(Compiler $compiler): void + { + $compiler->raw('continue'); + } + + public function optimize(Optimizer $optimizer): static + { + return new self(); + } +} diff --git a/src/Symfony/Component/Serializer/Php/ExpressionNode.php b/src/Symfony/Component/Serializer/Php/ExpressionNode.php new file mode 100644 index 0000000000000..8bec29f3bccb7 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/ExpressionNode.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + * + * @template T of PhpNodeInterface + */ +final readonly class ExpressionNode implements PhpNodeInterface +{ + /** + * @param T $node + */ + public function __construct( + public PhpNodeInterface $node, + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler->line($compiler->subcompile($this->node).';'); + } + + public function optimize(Optimizer $optimizer): static + { + return new self($optimizer->optimize($this->node)); + } +} diff --git a/src/Symfony/Component/Serializer/Php/ForEachNode.php b/src/Symfony/Component/Serializer/Php/ForEachNode.php new file mode 100644 index 0000000000000..4a04c617342cc --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/ForEachNode.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class ForEachNode implements PhpNodeInterface +{ + /** + * @param list $body + */ + public function __construct( + public PhpNodeInterface $collection, + public ?VariableNode $keyName, + public VariableNode $valueName, + public array $body, + ) { + } + + public function compile(Compiler $compiler): void + { + if (null === $this->keyName) { + $compiler->line(sprintf( + 'foreach (%s as %s) {', + $compiler->subcompile($this->collection), + $compiler->subcompile($this->valueName), + )); + } else { + $compiler->line(sprintf( + 'foreach (%s as %s => %s) {', + $compiler->subcompile($this->collection), + $compiler->subcompile($this->keyName), + $compiler->subcompile($this->valueName), + )); + } + + $compiler->indent(); + + foreach ($this->body as $bodyNode) { + $compiler->compile($bodyNode); + } + + $compiler + ->outdent() + ->line('}'); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $optimizer->optimize($this->collection), + null !== $this->keyName ? $optimizer->optimize($this->keyName) : null, + $optimizer->optimize($this->valueName), + $optimizer->optimize($this->body), + ); + } +} diff --git a/src/Symfony/Component/Serializer/Php/FunctionCallNode.php b/src/Symfony/Component/Serializer/Php/FunctionCallNode.php new file mode 100644 index 0000000000000..19749df310153 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/FunctionCallNode.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class FunctionCallNode implements PhpNodeInterface +{ + public function __construct( + public PhpNodeInterface|string $name, + public ArgumentsNode $arguments, + ) { + } + + public function compile(Compiler $compiler): void + { + $name = $this->name instanceof PhpNodeInterface ? sprintf('(%s)', $compiler->subcompile($this->name)) : $this->name; + + $compiler->raw(sprintf('%s(%s)', $name, $compiler->subcompile($this->arguments))); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $this->name instanceof PhpNodeInterface ? $optimizer->optimize($this->name) : $this->name, + $optimizer->optimize($this->arguments), + ); + } +} diff --git a/src/Symfony/Component/Serializer/Php/IfNode.php b/src/Symfony/Component/Serializer/Php/IfNode.php new file mode 100644 index 0000000000000..e825c093eef76 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/IfNode.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class IfNode implements PhpNodeInterface +{ + /** + * @param list $onIf + * @param list $onElse + * @param list}> $elseIfs + */ + public function __construct( + public PhpNodeInterface $condition, + public array $onIf, + public array $onElse = [], + public array $elseIfs = [], + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler + ->line(sprintf('if (%s) {', $compiler->subcompile($this->condition))) + ->indent(); + + foreach ($this->onIf as $ifBodyNode) { + $compiler->compile($ifBodyNode); + } + + $compiler->outdent(); + + foreach ($this->elseIfs as $elseIf) { + $compiler + ->line(sprintf('} elseif (%s) {', $compiler->subcompile($elseIf['condition']))) + ->indent(); + + foreach ($elseIf['body'] as $elseIfBodyNode) { + $compiler->compile($elseIfBodyNode); + } + + $compiler->outdent(); + } + + if ([] !== $this->onElse) { + $compiler + ->line('} else {') + ->indent(); + + foreach ($this->onElse as $elseBodyNode) { + $compiler->compile($elseBodyNode); + } + + $compiler->outdent(); + } + + $compiler->line('}'); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $optimizer->optimize($this->condition), + $optimizer->optimize($this->onIf), + $optimizer->optimize($this->onElse), + array_map(fn (array $e): array => [ + 'condition' => $optimizer->optimize($e['condition']), + 'body' => $optimizer->optimize($e['body']), + ], $this->elseIfs), + ); + } +} diff --git a/src/Symfony/Component/Serializer/Php/MethodCallNode.php b/src/Symfony/Component/Serializer/Php/MethodCallNode.php new file mode 100644 index 0000000000000..bf8a0401ba5eb --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/MethodCallNode.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class MethodCallNode implements PhpNodeInterface +{ + public function __construct( + public PhpNodeInterface $object, + public string $method, + public ArgumentsNode $arguments, + public bool $static = false, + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler + ->compile($this->object) + ->raw(sprintf('%s%s(%s)', $this->static ? '::' : '->', $this->method, $compiler->subcompile($this->arguments))); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $optimizer->optimize($this->object), + $this->method, + $optimizer->optimize($this->arguments), + $this->static, + ); + } +} diff --git a/src/Symfony/Component/Serializer/Php/NewNode.php b/src/Symfony/Component/Serializer/Php/NewNode.php new file mode 100644 index 0000000000000..5221037e3a10b --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/NewNode.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class NewNode implements PhpNodeInterface +{ + /** + * @param class-string $class + */ + public function __construct( + public string $class, + public ArgumentsNode $arguments, + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler->raw(sprintf('new %s(%s)', $this->class, $compiler->subcompile($this->arguments))); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $this->class, + $optimizer->optimize($this->arguments), + ); + } +} diff --git a/src/Symfony/Component/Serializer/Php/Optimizer.php b/src/Symfony/Component/Serializer/Php/Optimizer.php new file mode 100644 index 0000000000000..763b2423c07a2 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/Optimizer.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * Optimizes a PHP syntax tree. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class Optimizer +{ + /** + * @template T of PhpNodeInterface|list + * + * @param T $subject + * + * @return T + */ + public function optimize(PhpNodeInterface|array $subject): PhpNodeInterface|array + { + /** @var T $optimized */ + $optimized = $subject instanceof PhpNodeInterface ? $subject->optimize($this) : $this->optimizeNodeCollection($subject); + + return $optimized; + } + + /** + * @param list $nodes + * + * @return list + */ + private function optimizeNodeCollection(array $nodes): array + { + return $this->mergeResourceStringFwrites($nodes); + } + + /** + * @param list $nodes + * + * @return list + */ + private function mergeResourceStringFwrites(array $nodes): array + { + if (!array_is_list($nodes)) { + return $nodes; + } + + $createFwriteExpression = fn (string $content) => new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([ + new VariableNode('resource'), + new ScalarNode($content), + ]))); + + $stringContent = ''; + $mergedNodes = []; + + foreach ($nodes as $node) { + if (!$this->isStringResourceFwrite($node)) { + if ('' !== $stringContent) { + $mergedNodes[] = $createFwriteExpression($stringContent); + $stringContent = ''; + } + + $mergedNodes[] = $node; + + continue; + } + + /** + * @var ExpressionNode $node + * @var ScalarNode $stringArgument + */ + $stringArgument = $node->node->arguments->arguments[1]; + + $stringContent = $stringContent.$stringArgument->value; + } + + if ('' !== $stringContent) { + $mergedNodes[] = $createFwriteExpression($stringContent); + } + + /** @var list $optimizedNodes */ + $optimizedNodes = array_map($this->optimize(...), $mergedNodes); + + return $optimizedNodes; + } + + private function isStringResourceFwrite(PhpNodeInterface $node): bool + { + if (!$node instanceof ExpressionNode) { + return false; + } + + $currentNode = $node->node; + + if (!$currentNode instanceof FunctionCallNode || '\\fwrite' !== $currentNode->name) { + return false; + } + + $resourceArgument = $currentNode->arguments->arguments[0] ?? null; + $dataArgument = $currentNode->arguments->arguments[1] ?? null; + + return $resourceArgument instanceof VariableNode && 'resource' === $resourceArgument->name + && $dataArgument instanceof ScalarNode && \is_string($dataArgument->value); + } +} diff --git a/src/Symfony/Component/Serializer/Php/ParametersNode.php b/src/Symfony/Component/Serializer/Php/ParametersNode.php new file mode 100644 index 0000000000000..b60a9ffcf3fc1 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/ParametersNode.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class ParametersNode implements PhpNodeInterface +{ + /** + * @param array $parameters + */ + public function __construct( + public array $parameters, + ) { + } + + public function compile(Compiler $compiler): void + { + $argumentSources = []; + + foreach ($this->parameters as $name => $type) { + $byReference = false; + $type = $type ? $type.' ' : ''; + + if ('&' === ($name[0] ?? null)) { + $name = substr($name, 1); + $byReference = true; + } + + $argumentSources[] = sprintf('%s%s%s', $type, $byReference ? '&' : '', $compiler->subcompile(new VariableNode($name))); + } + + $compiler->raw(implode(', ', $argumentSources)); + } + + public function optimize(Optimizer $optimizer): static + { + return $this; + } +} diff --git a/src/Symfony/Component/Serializer/Php/PhpDocNode.php b/src/Symfony/Component/Serializer/Php/PhpDocNode.php new file mode 100644 index 0000000000000..2bde55ae1b186 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/PhpDocNode.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class PhpDocNode implements PhpNodeInterface +{ + /** + * @param list $lines + */ + public function __construct( + public array $lines, + ) { + } + + public function compile(Compiler $compiler): void + { + if ([] === $this->lines) { + return; + } + + $compiler->line('/**'); + foreach ($this->lines as $line) { + if ('' === $line) { + $compiler->line(' *'); + + continue; + } + + $compiler->line(' * '.$line); + } + $compiler->line(' */'); + } + + public function optimize(Optimizer $optimizer): static + { + return $this; + } +} diff --git a/src/Symfony/Component/Serializer/Php/PhpNodeInterface.php b/src/Symfony/Component/Serializer/Php/PhpNodeInterface.php new file mode 100644 index 0000000000000..007d2183d5824 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/PhpNodeInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * Represents a node in the PHP syntax tree. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface PhpNodeInterface +{ + public function compile(Compiler $compiler): void; + + public function optimize(Optimizer $optimizer): static; +} diff --git a/src/Symfony/Component/Serializer/Php/PropertyNode.php b/src/Symfony/Component/Serializer/Php/PropertyNode.php new file mode 100644 index 0000000000000..533809d765001 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/PropertyNode.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class PropertyNode implements PhpNodeInterface +{ + public function __construct( + public PhpNodeInterface $object, + public string $property, + public bool $static = false, + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler + ->compile($this->object) + ->raw(sprintf('%s%s', $this->static ? '::$' : '->', $this->property)); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $optimizer->optimize($this->object), + $this->property, + $this->static, + ); + } +} diff --git a/src/Symfony/Component/Serializer/Php/ReturnNode.php b/src/Symfony/Component/Serializer/Php/ReturnNode.php new file mode 100644 index 0000000000000..a9820da4f923b --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/ReturnNode.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class ReturnNode implements PhpNodeInterface +{ + public function __construct( + public ?PhpNodeInterface $node, + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler->raw(sprintf( + 'return%s', + null !== $this->node ? ' '.$compiler->subcompile($this->node) : '', + )); + } + + public function optimize(Optimizer $optimizer): static + { + return new self(null !== $this->node ? $optimizer->optimize($this->node) : null); + } +} diff --git a/src/Symfony/Component/Serializer/Php/ScalarNode.php b/src/Symfony/Component/Serializer/Php/ScalarNode.php new file mode 100644 index 0000000000000..c3867cba60b93 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/ScalarNode.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class ScalarNode implements PhpNodeInterface +{ + public function __construct( + public mixed $value, + ) { + } + + public function compile(Compiler $compiler): void + { + if (null === $this->value) { + $compiler->raw('null'); + + return; + } + + if (\is_int($this->value) || \is_float($this->value)) { + $compiler->raw((string) $this->value); + + return; + } + + if (\is_bool($this->value)) { + $compiler->raw($this->value ? 'true' : 'false'); + + return; + } + + if (\is_string($this->value)) { + $compiler->raw(sprintf('"%s"', addcslashes($this->value, '"\\'))); + + return; + } + + throw new InvalidArgumentException(sprintf('Given value is not a scalar. Got "%s".', get_debug_type($this->value))); + } + + public function optimize(Optimizer $optimizer): static + { + return $this; + } +} diff --git a/src/Symfony/Component/Serializer/Php/TemplateStringNode.php b/src/Symfony/Component/Serializer/Php/TemplateStringNode.php new file mode 100644 index 0000000000000..44b3bdc8e4662 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/TemplateStringNode.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class TemplateStringNode implements PhpNodeInterface +{ + /** + * @var list + */ + public array $parts; + + public function __construct(string|VariableNode ...$parts) + { + $this->parts = array_values($parts); + } + + public function compile(Compiler $compiler): void + { + $templateString = ''; + foreach ($this->parts as $part) { + if (\is_string($part)) { + $templateString .= sprintf('%s', addcslashes($part, '"\\')); + + continue; + } + + $templateString .= sprintf('{%s}', $compiler->subcompile($part)); + } + + $compiler->raw(sprintf('"%s"', $templateString)); + } + + public function optimize(Optimizer $optimizer): static + { + return new self(...array_map(fn (VariableNode|string $p): VariableNode|string => \is_string($p) ? $p : $optimizer->optimize($p), $this->parts)); + } +} diff --git a/src/Symfony/Component/Serializer/Php/TernaryConditionNode.php b/src/Symfony/Component/Serializer/Php/TernaryConditionNode.php new file mode 100644 index 0000000000000..9372d84980b48 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/TernaryConditionNode.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class TernaryConditionNode implements PhpNodeInterface +{ + public function __construct( + public PhpNodeInterface $condition, + public PhpNodeInterface $onTrue, + public PhpNodeInterface $onFalse, + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler + ->raw('(') + ->compile($this->condition) + ->raw(' ? ') + ->compile($this->onTrue) + ->raw(' : ') + ->compile($this->onFalse) + ->raw(')'); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $optimizer->optimize($this->condition), + $optimizer->optimize($this->onTrue), + $optimizer->optimize($this->onFalse), + ); + } +} diff --git a/src/Symfony/Component/Serializer/Php/ThrowNode.php b/src/Symfony/Component/Serializer/Php/ThrowNode.php new file mode 100644 index 0000000000000..27f3eb6d9200a --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/ThrowNode.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class ThrowNode implements PhpNodeInterface +{ + public function __construct( + public PhpNodeInterface $node, + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler->raw(sprintf('throw %s', $compiler->subcompile($this->node))); + } + + public function optimize(Optimizer $optimizer): static + { + return new self($optimizer->optimize($this->node)); + } +} diff --git a/src/Symfony/Component/Serializer/Php/TryCatchNode.php b/src/Symfony/Component/Serializer/Php/TryCatchNode.php new file mode 100644 index 0000000000000..3a4909c58d922 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/TryCatchNode.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class TryCatchNode implements PhpNodeInterface +{ + /** + * @param list $tryNodes + * @param list $catchNodes + */ + public function __construct( + public array $tryNodes, + public array $catchNodes, + public ParametersNode $catchParameters, + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler + ->line('try {') + ->indent(); + + foreach ($this->tryNodes as $node) { + $compiler->compile($node); + } + + $compiler + ->outdent() + ->line(sprintf('} catch (%s) {', $compiler->subcompile($this->catchParameters))) + ->indent(); + + foreach ($this->catchNodes as $node) { + $compiler->compile($node); + } + + $compiler + ->outdent() + ->line('}'); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $optimizer->optimize($this->tryNodes), + $optimizer->optimize($this->catchNodes), + $optimizer->optimize($this->catchParameters), + ); + } +} diff --git a/src/Symfony/Component/Serializer/Php/UnaryNode.php b/src/Symfony/Component/Serializer/Php/UnaryNode.php new file mode 100644 index 0000000000000..242a06f7d6ec5 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/UnaryNode.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class UnaryNode implements PhpNodeInterface +{ + private const OPERATORS = [ + '!', + ]; + + public function __construct( + public string $operator, + public PhpNodeInterface $node, + ) { + if (!\in_array($this->operator, self::OPERATORS)) { + throw new InvalidArgumentException(sprintf('Invalid "%s" operator.', $this->operator)); + } + } + + public function compile(Compiler $compiler): void + { + $compiler + ->raw($this->operator) + ->compile($this->node); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $this->operator, + $optimizer->optimize($this->node), + ); + } +} diff --git a/src/Symfony/Component/Serializer/Php/VariableNode.php b/src/Symfony/Component/Serializer/Php/VariableNode.php new file mode 100644 index 0000000000000..96411b1d6ddfd --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/VariableNode.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class VariableNode implements PhpNodeInterface +{ + public function __construct( + public string $name, + public bool $byReference = false, + ) { + } + + public function compile(Compiler $compiler): void + { + $compiler->raw(sprintf('%s$%s', $this->byReference ? '&' : '', $this->name)); + } + + public function optimize(Optimizer $optimizer): static + { + return $this; + } +} diff --git a/src/Symfony/Component/Serializer/Php/YieldNode.php b/src/Symfony/Component/Serializer/Php/YieldNode.php new file mode 100644 index 0000000000000..27efed55273b3 --- /dev/null +++ b/src/Symfony/Component/Serializer/Php/YieldNode.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Php; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class YieldNode implements PhpNodeInterface +{ + public function __construct( + public PhpNodeInterface $value, + public ?PhpNodeInterface $key = null, + ) { + } + + public function compile(Compiler $compiler): void + { + if (null === $this->key) { + $compiler->raw(sprintf('yield %s', $compiler->subcompile($this->value))); + + return; + } + + $compiler->raw(sprintf( + 'yield %s => %s', + $compiler->subcompile($this->key), + $compiler->subcompile($this->value), + )); + } + + public function optimize(Optimizer $optimizer): static + { + return new self( + $optimizer->optimize($this->value), + $optimizer->optimize($this->key), + ); + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/Config/CsvSerializeConfig.php b/src/Symfony/Component/Serializer/Serialize/Config/CsvSerializeConfig.php new file mode 100644 index 0000000000000..350864d90cc3f --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/Config/CsvSerializeConfig.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\Config; + +use Symfony\Component\Serializer\Config\CsvConfig; + +/** + * CSV format serialization configuration. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +class CsvSerializeConfig extends CsvConfig +{ +} diff --git a/src/Symfony/Component/Serializer/Serialize/Config/JsonSerializeConfig.php b/src/Symfony/Component/Serializer/Serialize/Config/JsonSerializeConfig.php new file mode 100644 index 0000000000000..e6447e1c2e48a --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/Config/JsonSerializeConfig.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\Config; + +use Symfony\Component\Serializer\Config\JsonConfig; + +/** + * JSON format serialization configuration. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +class JsonSerializeConfig extends JsonConfig +{ + public function __construct() + { + $this->flags = \JSON_PRESERVE_ZERO_FRACTION; + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/Config/SerializeConfig.php b/src/Symfony/Component/Serializer/Serialize/Config/SerializeConfig.php new file mode 100644 index 0000000000000..9c4d978d566ba --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/Config/SerializeConfig.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\Config; + +use Symfony\Component\Serializer\Type\Type; + +/** + * Serialization base configuration. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +class SerializeConfig +{ + protected ?Type $type = null; + + /** + * @var list + */ + protected array $groups = []; + + protected bool $forceGenerateTemplate = false; + + protected int $maxDepth = 32; + + protected string $dateTimeFormat = \DateTimeInterface::RFC3339; + + protected JsonSerializeConfig $jsonConfig; + + protected CsvSerializeConfig $csvConfig; + + public function __construct() + { + $this->jsonConfig = new JsonSerializeConfig(); + $this->csvConfig = new CsvSerializeConfig(); + } + + public function type(): ?Type + { + return $this->type; + } + + public function withType(Type $type): static + { + $clone = clone $this; + $clone->type = $type; + + return $clone; + } + + /** + * @return list + */ + public function groups(): array + { + return $this->groups; + } + + /** + * @param non-empty-string|non-empty-array $groups + */ + public function withGroups(array|string $groups): static + { + $clone = clone $this; + $clone->groups = array_values(array_unique((array) $groups)); + + return $clone; + } + + public function forceGenerateTemplate(): bool + { + return $this->forceGenerateTemplate; + } + + public function withForceGenerateTemplate(bool $forceGenerateTemplate = true): static + { + $clone = clone $this; + $clone->forceGenerateTemplate = $forceGenerateTemplate; + + return $clone; + } + + /** + * @return positive-int + */ + public function maxDepth(): int + { + return $this->maxDepth; + } + + /** + * @param positive-int $maxDepth + */ + public function withMaxDepth(int $maxDepth): static + { + $clone = clone $this; + $clone->maxDepth = $maxDepth; + + return $clone; + } + + public function dateTimeFormat(): string + { + return $this->dateTimeFormat; + } + + public function withDateTimeFormat(string $dateTimeFormat): static + { + $clone = clone $this; + $clone->dateTimeFormat = $dateTimeFormat; + + return $clone; + } + + public function json(): JsonSerializeConfig + { + return $this->jsonConfig; + } + + public function withJsonConfig(JsonSerializeConfig $config): static + { + $clone = clone $this; + $clone->jsonConfig = $config; + + return $clone; + } + + public function csv(): CsvSerializeConfig + { + return $this->csvConfig; + } + + public function withCsvConfig(CsvSerializeConfig $config): static + { + $clone = clone $this; + $clone->csvConfig = $config; + + return $clone; + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/DataModel/CollectionNode.php b/src/Symfony/Component/Serializer/Serialize/DataModel/CollectionNode.php new file mode 100644 index 0000000000000..db3617e78727c --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/DataModel/CollectionNode.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\DataModel; + +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Type\Type; + +/** + * Represents a collection in the data model graph representation. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class CollectionNode implements DataModelNodeInterface +{ + public function __construct( + public PhpNodeInterface $accessor, + public Type $type, + public DataModelNodeInterface $item, + ) { + } + + public function type(): Type + { + return $this->type; + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/DataModel/DataModelBuilder.php b/src/Symfony/Component/Serializer/Serialize/DataModel/DataModelBuilder.php new file mode 100644 index 0000000000000..56f7a5673d477 --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/DataModel/DataModelBuilder.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\DataModel; + +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Exception\MaxDepthException; +use Symfony\Component\Serializer\Php\ArgumentsNode; +use Symfony\Component\Serializer\Php\FunctionCallNode; +use Symfony\Component\Serializer\Php\MethodCallNode; +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Php\PropertyNode; +use Symfony\Component\Serializer\Php\ScalarNode as PhpScalarNode; +use Symfony\Component\Serializer\Php\VariableNode; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Serialize\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\Serializer\Serialize\VariableNameScoperTrait; +use Symfony\Component\Serializer\Type\Type; +use Symfony\Component\VarExporter\ProxyHelper; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class DataModelBuilder implements DataModelBuilderInterface +{ + use VariableNameScoperTrait; + + public function __construct( + private readonly PropertyMetadataLoaderInterface $propertyMetadataLoader, + private readonly ContainerInterface $runtimeServices, + ) { + } + + public function build(Type $type, PhpNodeInterface $accessor, SerializeConfig $config, array $context = []): DataModelNodeInterface + { + if ($type->isObject() && $type->hasClass()) { + $className = $type->className(); + + $context['depth_counters'][$className] ??= 0; + ++$context['depth_counters'][$className]; + + if ($context['depth_counters'][$className] > $config->maxDepth()) { + throw new MaxDepthException($className, $config->maxDepth()); + } + + $propertiesNodes = []; + + foreach ($this->propertyMetadataLoader->load($className, $config, ['original_type' => $type] + $context) as $serializedName => $propertyMetadata) { + $propertyAccessor = new PropertyNode($accessor, $propertyMetadata->name()); + + foreach ($propertyMetadata->formatters() as $f) { + $reflection = new \ReflectionFunction(\Closure::fromCallable($f)); + $functionName = null === $reflection->getClosureScopeClass() + ? $reflection->getName() + : sprintf('%s::%s', $reflection->getClosureScopeClass()->getName(), $reflection->getName()); + + $arguments = []; + foreach ($reflection->getParameters() as $i => $parameter) { + if (0 === $i) { + $arguments[] = $propertyAccessor; + + continue; + } + + $parameterType = preg_replace('/(^|[(|&])\\\\/', '\1', ltrim(ProxyHelper::exportType($parameter) ?? '', '?')); + if (is_a($parameterType, SerializeConfig::class, allow_string: true)) { + $arguments[] = new VariableNode('config'); + + continue; + } + + $argumentName = sprintf('%s[%s]', $functionName, $parameter->name); + if ($this->runtimeServices->has($argumentName)) { + $arguments[] = new MethodCallNode(new VariableNode('services'), 'get', new ArgumentsNode([new PhpScalarNode($argumentName)])); + + continue; + } + + throw new LogicException(sprintf('Cannot resolve "%s" argument of "%s()".', $parameter->name, $functionName)); + } + + $propertyAccessor = new FunctionCallNode($functionName, new ArgumentsNode($arguments)); + } + + $propertiesNodes[$serializedName] = $this->build($propertyMetadata->type(), $propertyAccessor, $config, $context); + } + + return new ObjectNode($accessor, $type, $propertiesNodes); + } + + if ($type->isCollection() && ($type->isList() || $type->isDict())) { + return new CollectionNode( + $accessor, + $type, + $this->build($type->collectionValueType(), new VariableNode($this->scopeVariableName('value', $context)), $config, $context), + ); + } + + return new ScalarNode($accessor, $type); + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/DataModel/DataModelBuilderInterface.php b/src/Symfony/Component/Serializer/Serialize/DataModel/DataModelBuilderInterface.php new file mode 100644 index 0000000000000..917b8a8b225dc --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/DataModel/DataModelBuilderInterface.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\DataModel; + +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Type\Type; + +/** + * Creates a data model graph representation of a given type. + * + * This data model will be used by the {@see Symfony\Component\Serializer\Serialize\Template\TemplateGeneratorInterface} + * to generate a PHP serialization template. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface DataModelBuilderInterface +{ + /** + * @param array $context + */ + public function build(Type $type, PhpNodeInterface $accessor, SerializeConfig $config, array $context = []): DataModelNodeInterface; +} diff --git a/src/Symfony/Component/Serializer/Serialize/DataModel/DataModelNodeInterface.php b/src/Symfony/Component/Serializer/Serialize/DataModel/DataModelNodeInterface.php new file mode 100644 index 0000000000000..71c6a85abf7bf --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/DataModel/DataModelNodeInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\DataModel; + +use Symfony\Component\Serializer\Type\Type; + +/** + * Represents a node in the data model graph representation. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface DataModelNodeInterface +{ + public function type(): Type; +} diff --git a/src/Symfony/Component/Serializer/Serialize/DataModel/ObjectNode.php b/src/Symfony/Component/Serializer/Serialize/DataModel/ObjectNode.php new file mode 100644 index 0000000000000..9f2bac742b07f --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/DataModel/ObjectNode.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\DataModel; + +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Type\Type; + +/** + * Represents an object in the data model graph representation. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class ObjectNode implements DataModelNodeInterface +{ + /** + * @param array $properties + */ + public function __construct( + public PhpNodeInterface $accessor, + public Type $type, + public array $properties, + ) { + } + + public function type(): Type + { + return $this->type; + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/DataModel/ScalarNode.php b/src/Symfony/Component/Serializer/Serialize/DataModel/ScalarNode.php new file mode 100644 index 0000000000000..cb6fe3cdbcfa2 --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/DataModel/ScalarNode.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\DataModel; + +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Type\Type; + +/** + * Represents a scalar in the data model graph representation. + * + * Scalars are the leaves of the data model tree. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class ScalarNode implements DataModelNodeInterface +{ + public function __construct( + public PhpNodeInterface $accessor, + public Type $type, + ) { + } + + public function type(): Type + { + return $this->type; + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/Encoder/CsvEncoder.php b/src/Symfony/Component/Serializer/Serialize/Encoder/CsvEncoder.php new file mode 100644 index 0000000000000..b529b3e8d3227 --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/Encoder/CsvEncoder.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\Encoder; + +use Symfony\Component\Serializer\Encoder\CsvEncoder as LegacyCsvEncoder; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class CsvEncoder implements EncoderInterface +{ + private static LegacyCsvEncoder|null $legacyCsvEncoder = null; + + public static function encode(mixed $resource, mixed $normalized, SerializeConfig $config): void + { + $csvConfig = $config->csv(); + + $legacyContext = [ + LegacyCsvEncoder::DELIMITER_KEY => $csvConfig->delimiter(), + LegacyCsvEncoder::ENCLOSURE_KEY => $csvConfig->enclosure(), + LegacyCsvEncoder::ESCAPE_CHAR_KEY => $csvConfig->escapeChar(), + LegacyCsvEncoder::END_OF_LINE => $csvConfig->endOfLine(), + LegacyCsvEncoder::ESCAPE_FORMULAS_KEY => $csvConfig->escapedFormulas(), + LegacyCsvEncoder::HEADERS_KEY => $csvConfig->headers(), + LegacyCsvEncoder::KEY_SEPARATOR_KEY => $csvConfig->keySeparator(), + LegacyCsvEncoder::NO_HEADERS_KEY => $csvConfig->noHeaders(), + LegacyCsvEncoder::AS_COLLECTION_KEY => $csvConfig->asCollection(), + LegacyCsvEncoder::OUTPUT_UTF8_BOM_KEY => $csvConfig->utf8Bom(), + ]; + + $legacyCsvEncoder = self::$legacyCsvEncoder ??= new LegacyCsvEncoder(); + + fwrite($resource, $legacyCsvEncoder->encode($normalized, 'csv', $legacyContext)); + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/Encoder/EncoderInterface.php b/src/Symfony/Component/Serializer/Serialize/Encoder/EncoderInterface.php new file mode 100644 index 0000000000000..a35f80b9f1673 --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/Encoder/EncoderInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\Encoder; + +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; + +/** + * Encode $normalized data into a $resource stream. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface EncoderInterface +{ + /** + * @param resource $resource + */ + public static function encode(mixed $resource, mixed $normalized, SerializeConfig $config): void; +} diff --git a/src/Symfony/Component/Serializer/Serialize/Mapping/AttributePropertyMetadataLoader.php b/src/Symfony/Component/Serializer/Serialize/Mapping/AttributePropertyMetadataLoader.php new file mode 100644 index 0000000000000..5253cc7be70b1 --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/Mapping/AttributePropertyMetadataLoader.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\Mapping; + +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\MaxDepth; +use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Serializer\Attribute\SerializeFormatter; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Type\TypeExtractorInterface; + +/** + * Enhance properties serialization metadata based on properties' attributes. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class AttributePropertyMetadataLoader implements PropertyMetadataLoaderInterface +{ + public function __construct( + private readonly PropertyMetadataLoaderInterface $decorated, + private readonly TypeExtractorInterface $typeExtractor, + ) { + } + + public function load(string $className, SerializeConfig $config, array $context): array + { + $initialResult = $this->decorated->load($className, $config, $context); + $result = []; + + foreach ($initialResult as $initialSerializedName => $initialMetadata) { + $attributesMetadata = $this->propertyAttributesMetadata(new \ReflectionProperty($className, $initialMetadata->name())); + + if ([] !== $config->groups()) { + $matchingGroup = false; + foreach ($config->groups() as $group) { + if (isset($attributesMetadata['groups'][$group])) { + $matchingGroup = true; + + break; + } + } + + if (!$matchingGroup) { + continue; + } + } + + $serializedName = $attributesMetadata['name'] ?? $initialSerializedName; + + if (isset($attributesMetadata['max_depth']) && ($context['depth_counters'][$className] ?? 0) > $attributesMetadata['max_depth']) { + if (null === $formatter = $attributesMetadata['max_depth_reached_formatter'] ?? null) { + continue; + } + + $reflectionFormatter = new \ReflectionFunction(\Closure::fromCallable($formatter)); + $type = $this->typeExtractor->extractTypeFromFunctionReturn($reflectionFormatter); + + $result[$serializedName] = $initialMetadata + ->withType($type) + ->withFormatter($formatter(...)); + + continue; + } + + if (null !== $formatter = $attributesMetadata['formatter'] ?? null) { + $reflectionFormatter = new \ReflectionFunction(\Closure::fromCallable($formatter)); + $type = $this->typeExtractor->extractTypeFromFunctionReturn($reflectionFormatter); + + $result[$serializedName] = $initialMetadata + ->withType($type) + ->withFormatter($formatter(...)); + + continue; + } + + $result[$serializedName] = $initialMetadata; + } + + return $result; + } + + /** + * @return array{groups?: array, name?: string, formatter?: callable, max_depth?: int, max_depth_reached_formatter?: ?callable} + */ + private function propertyAttributesMetadata(\ReflectionProperty $reflectionProperty): array + { + $metadata = []; + + $reflectionAttribute = $reflectionProperty->getAttributes(Groups::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; + if (null !== $reflectionAttribute) { + foreach ($reflectionAttribute->newInstance()->groups as $group) { + $metadata['groups'][$group] = true; + } + } + + $reflectionAttribute = $reflectionProperty->getAttributes(SerializedName::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; + if (null !== $reflectionAttribute) { + $metadata['name'] = $reflectionAttribute->newInstance()->name; + } + + $reflectionAttribute = $reflectionProperty->getAttributes(SerializeFormatter::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; + if (null !== $reflectionAttribute) { + $metadata['formatter'] = $reflectionAttribute->newInstance()->formatter; + } + + $reflectionAttribute = $reflectionProperty->getAttributes(MaxDepth::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; + if (null !== $reflectionAttribute) { + $attributeInstance = $reflectionAttribute->newInstance(); + + $metadata['max_depth'] = $attributeInstance->maxDepth; + $metadata['max_depth_reached_formatter'] = $attributeInstance->maxDepthReachedFormatter; + } + + return $metadata; + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/Mapping/PropertyMetadata.php b/src/Symfony/Component/Serializer/Serialize/Mapping/PropertyMetadata.php new file mode 100644 index 0000000000000..4eaf6f0cf9896 --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/Mapping/PropertyMetadata.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\Mapping; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Type\Type; + +/** + * Holds serialization metadata about a given property. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class PropertyMetadata +{ + /** + * @param list $formatters + */ + public function __construct( + private string $name, + private Type $type, + private array $formatters = [], + ) { + self::validateFormatters($this); + } + + public function name(): string + { + return $this->name; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function type(): Type + { + return $this->type; + } + + public function withType(Type $type): self + { + $clone = clone $this; + $clone->type = $type; + + return $clone; + } + + /** + * @return list + */ + public function formatters(): array + { + return $this->formatters; + } + + /** + * @param list $formatters + */ + public function withFormatters(array $formatters): self + { + $clone = clone $this; + $clone->formatters = $formatters; + + self::validateFormatters($clone); + + return $clone; + } + + public function withFormatter(callable $formatter): self + { + $formatters = $this->formatters; + $formatters[] = $formatter; + + return $this->withFormatters($formatters); + } + + private static function validateFormatters(self $metadata): void + { + foreach ($metadata->formatters as $formatter) { + $reflection = new \ReflectionFunction(\Closure::fromCallable($formatter)); + + if ($reflection->getClosureScopeClass()?->hasMethod($reflection->getName())) { + if (!$reflection->isStatic()) { + throw new InvalidArgumentException(sprintf('"%s"\'s property serialize formatter must be a static method.', $metadata->name)); + } + } else { + if ($reflection->isAnonymous()) { + throw new InvalidArgumentException(sprintf('"%s"\'s property serialize formatter must not be anonymous.', $metadata->name)); + } + } + } + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/Mapping/PropertyMetadataLoader.php b/src/Symfony/Component/Serializer/Serialize/Mapping/PropertyMetadataLoader.php new file mode 100644 index 0000000000000..94ee5d653fee1 --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/Mapping/PropertyMetadataLoader.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\Mapping; + +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Type\TypeExtractorInterface; + +/** + * Loads basic properties serialization metadata for a given $className. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class PropertyMetadataLoader implements PropertyMetadataLoaderInterface +{ + public function __construct( + private readonly TypeExtractorInterface $typeExtractor, + ) { + } + + public function load(string $className, SerializeConfig $config, array $context): array + { + $result = []; + + foreach ((new \ReflectionClass($className))->getProperties() as $reflectionProperty) { + if (!$reflectionProperty->isPublic()) { + continue; + } + + $name = $serializedName = $reflectionProperty->getName(); + $type = $this->typeExtractor->extractTypeFromProperty($reflectionProperty); + + $result[$serializedName] = new PropertyMetadata($name, $type); + } + + return $result; + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/Mapping/PropertyMetadataLoaderInterface.php b/src/Symfony/Component/Serializer/Serialize/Mapping/PropertyMetadataLoaderInterface.php new file mode 100644 index 0000000000000..9d9989648955b --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/Mapping/PropertyMetadataLoaderInterface.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\Mapping; + +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; + +/** + * Loads properties serialization metadata for a given $className. + * + * This metadata can be used by the {@see Symfony\Component\Serializer\Serialize\DataModel\DataModelBuilderInterface} + * to create a more appropriate {@see Symfony\Component\Serializer\Serialize\DataModel\ObjectNode}. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface PropertyMetadataLoaderInterface +{ + /** + * @param array $context + * @param class-string $className + * + * @return array + */ + public function load(string $className, SerializeConfig $config, array $context): array; +} diff --git a/src/Symfony/Component/Serializer/Serialize/Mapping/TypePropertyMetadataLoader.php b/src/Symfony/Component/Serializer/Serialize/Mapping/TypePropertyMetadataLoader.php new file mode 100644 index 0000000000000..0e1fe0a4da47d --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/Mapping/TypePropertyMetadataLoader.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\Mapping; + +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Type\Type; +use Symfony\Component\Serializer\Type\TypeExtractorInterface; +use Symfony\Component\Serializer\Type\TypeGenericsHelper; + +/** + * Enhance properties serialization metadata based on properties' type. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class TypePropertyMetadataLoader implements PropertyMetadataLoaderInterface +{ + private readonly TypeGenericsHelper $typeGenericsHelper; + + public function __construct( + private readonly PropertyMetadataLoaderInterface $decorated, + TypeExtractorInterface $typeExtractor, + ) { + $this->typeGenericsHelper = new TypeGenericsHelper($typeExtractor); + } + + public function load(string $className, SerializeConfig $config, array $context): array + { + $result = $this->decorated->load($className, $config, $context); + $genericTypes = $this->typeGenericsHelper->classGenericTypes($className, $context['original_type']); + + foreach ($result as &$metadata) { + $type = $metadata->type(); + + if (isset($genericTypes[(string) $type])) { + $metadata = $metadata->withType($this->typeGenericsHelper->replaceGenericTypes($type, $genericTypes)); + $type = $metadata->type(); + } + + if ($type->isObject() && $type->hasClass() && is_a($type->className(), \DateTimeInterface::class, true)) { + $metadata = $metadata + ->withType(Type::string()) + ->withFormatter(self::castDateTimeToString(...)); + } + } + + return $result; + } + + public static function castDateTimeToString(\DateTimeInterface $dateTime, SerializeConfig $config): string + { + return $dateTime->format($config->dateTimeFormat()); + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/Serializer.php b/src/Symfony/Component/Serializer/Serialize/Serializer.php new file mode 100644 index 0000000000000..f84336003d158 --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/Serializer.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize; + +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\Exception\RuntimeException; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Serialize\Template\Template; +use Symfony\Component\Serializer\Stream\MemoryStream; +use Symfony\Component\Serializer\Stream\StreamInterface; +use Symfony\Component\Serializer\Type\Type; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class Serializer implements SerializerInterface +{ + public function __construct( + private readonly Template $template, + private readonly ContainerInterface $runtimeServices, + private readonly string $templateCacheDir, + ) { + } + + public function serialize(mixed $data, string $format, StreamInterface $output = null, SerializeConfig $config = null): string|null + { + $shouldOutputString = null === $output; + + $output ??= new MemoryStream(); + $config ??= new SerializeConfig(); + $type = $config->type() ?? Type::fromString(get_debug_type($data)); + $path = $this->template->path($type, $format, $config); + + if (!file_exists($path) || $config->forceGenerateTemplate()) { + $content = $this->template->content($type, $format, $config); + + if (!file_exists($this->templateCacheDir)) { + mkdir($this->templateCacheDir, recursive: true); + } + + $tmpFile = @tempnam(\dirname($path), basename($path)); + if (false === @file_put_contents($tmpFile, $content)) { + throw new RuntimeException(sprintf('Failed to write "%s" template file.', $path)); + } + + @rename($tmpFile, $path); + @chmod($path, 0666 & ~umask()); + } + + (require $path)($data, $output->resource(), $config, $this->runtimeServices); + + return $shouldOutputString ? (string) $output : null; + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/SerializerInterface.php b/src/Symfony/Component/Serializer/Serialize/SerializerInterface.php new file mode 100644 index 0000000000000..4decf83920294 --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/SerializerInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize; + +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Stream\StreamInterface; + +/** + * Serializes $data into a specific $format and $config to a string or into a given $output stream. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface SerializerInterface +{ + public function serialize(mixed $data, string $format, StreamInterface $output = null, SerializeConfig $config = null): string|null; +} diff --git a/src/Symfony/Component/Serializer/Serialize/Template/JsonTemplateGenerator.php b/src/Symfony/Component/Serializer/Serialize/Template/JsonTemplateGenerator.php new file mode 100644 index 0000000000000..aa1bec17cbc39 --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/Template/JsonTemplateGenerator.php @@ -0,0 +1,181 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\Template; + +use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Exception\RuntimeException; +use Symfony\Component\Serializer\Php\ArgumentsNode; +use Symfony\Component\Serializer\Php\AssignNode; +use Symfony\Component\Serializer\Php\BinaryNode; +use Symfony\Component\Serializer\Php\ExpressionNode; +use Symfony\Component\Serializer\Php\ForEachNode; +use Symfony\Component\Serializer\Php\FunctionCallNode; +use Symfony\Component\Serializer\Php\IfNode; +use Symfony\Component\Serializer\Php\MethodCallNode; +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Php\PropertyNode; +use Symfony\Component\Serializer\Php\ScalarNode as PhpScalarNode; +use Symfony\Component\Serializer\Php\TemplateStringNode; +use Symfony\Component\Serializer\Php\VariableNode; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Serialize\DataModel\CollectionNode; +use Symfony\Component\Serializer\Serialize\DataModel\DataModelNodeInterface; +use Symfony\Component\Serializer\Serialize\DataModel\ObjectNode; +use Symfony\Component\Serializer\Serialize\DataModel\ScalarNode; +use Symfony\Component\Serializer\Serialize\VariableNameScoperTrait; + +/** + * Generates a template PHP syntax tree that serializes data to JSON. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class JsonTemplateGenerator implements TemplateGeneratorInterface +{ + use VariableNameScoperTrait; + + public function generate(DataModelNodeInterface $node, SerializeConfig $config, array $context): array + { + if ($node instanceof CollectionNode) { + $prefixName = $this->scopeVariableName('prefix', $context); + + if ($node->type->isList()) { + $listNodes = [ + new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode('[')]))), + new ExpressionNode(new AssignNode(new VariableNode($prefixName), new PhpScalarNode(''))), + + new ForEachNode($node->accessor, null, $node->item->accessor, [ + new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new VariableNode($prefixName)]))), + ...$this->generate($node->item, $config, $context), + new ExpressionNode(new AssignNode(new VariableNode($prefixName), new PhpScalarNode(','))), + ]), + + new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode(']')]))), + ]; + + if ($node->type->isNullable()) { + return [ + new IfNode(new BinaryNode('===', new PhpScalarNode(null), $node->accessor), [ + new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode('null')]))), + ], $listNodes), + ]; + } + + return $listNodes; + } + + $keyName = $this->scopeVariableName('key', $context); + + $dictNodes = [ + new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode('{')]))), + new ExpressionNode(new AssignNode(new VariableNode($prefixName), new PhpScalarNode(''))), + + new ForEachNode($node->accessor, new VariableNode($keyName), $node->item->accessor, [ + new ExpressionNode(new AssignNode(new VariableNode($keyName), $this->escapeString(new VariableNode($keyName)))), + new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([ + new VariableNode('resource'), + new TemplateStringNode(new VariableNode($prefixName), '"', new VariableNode($keyName), '":'), + ]))), + ...$this->generate($node->item, $config, $context), + new ExpressionNode(new AssignNode(new VariableNode($prefixName), new PhpScalarNode(','))), + ]), + + new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode('}')]))), + ]; + + if ($node->type->isNullable()) { + return [ + new IfNode(new BinaryNode('===', new PhpScalarNode(null), $node->accessor), [ + new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode('null')]))), + ], $dictNodes), + ]; + } + + return $dictNodes; + } + + if ($node instanceof ObjectNode) { + $objectNodes = [new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode('{')])))]; + $separator = ''; + + foreach ($node->properties as $name => $propertyNode) { + $encodedName = json_encode($name); + if (false === $encodedName) { + throw new RuntimeException(sprintf('Cannot encode "%s"', $name)); + } + + $encodedName = substr($encodedName, 1, -1); + + array_push( + $objectNodes, + new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode($separator)]))), + new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode('"')]))), + new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode($encodedName)]))), + new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode('":')]))), + ...$this->generate($propertyNode, $config, $context), + ); + + $separator = ','; + } + + $objectNodes[] = new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode('}')]))); + + if ($node->type->isNullable()) { + return [ + new IfNode(new BinaryNode('===', new PhpScalarNode(null), $node->accessor), [ + new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode('null')]))), + ], $objectNodes), + ]; + } + + return $objectNodes; + } + + if ($node instanceof ScalarNode) { + $scalarAccessor = $node->type->isBackedEnum() ? new PropertyNode($node->accessor, 'value') : $node->accessor; + $scalarNodes = [new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), $this->encodeValue($scalarAccessor)])))]; + + if ($node->type->isNullable()) { + return [ + new IfNode(new BinaryNode('===', new PhpScalarNode(null), $node->accessor), [ + new ExpressionNode(new FunctionCallNode('\fwrite', new ArgumentsNode([new VariableNode('resource'), new PhpScalarNode('null')]))), + ], $scalarNodes), + ]; + } + + return $scalarNodes; + } + + throw new LogicException(sprintf('Unexpected "%s" node', $node::class)); + } + + private function encodeValue(PhpNodeInterface $node): PhpNodeInterface + { + return new FunctionCallNode('\json_encode', new ArgumentsNode([ + $node, + new MethodCallNode(new MethodCallNode(new VariableNode('config'), 'json', new ArgumentsNode([])), 'flags', new ArgumentsNode([])), + ])); + } + + private function escapeString(PhpNodeInterface $node): PhpNodeInterface + { + return new FunctionCallNode('\substr', new ArgumentsNode([ + new FunctionCallNode('\json_encode', new ArgumentsNode([ + $node, + new MethodCallNode(new MethodCallNode(new VariableNode('config'), 'json', new ArgumentsNode([])), 'flags', new ArgumentsNode([])), + ])), + new PhpScalarNode(1), + new PhpScalarNode(-1), + ])); + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/Template/NormalizerEncoderTemplateGenerator.php b/src/Symfony/Component/Serializer/Serialize/Template/NormalizerEncoderTemplateGenerator.php new file mode 100644 index 0000000000000..5d8eaf2e59b32 --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/Template/NormalizerEncoderTemplateGenerator.php @@ -0,0 +1,128 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\Template; + +use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Php\ArgumentsNode; +use Symfony\Component\Serializer\Php\ArrayAccessNode; +use Symfony\Component\Serializer\Php\ArrayNode; +use Symfony\Component\Serializer\Php\AssignNode; +use Symfony\Component\Serializer\Php\BinaryNode; +use Symfony\Component\Serializer\Php\ExpressionNode; +use Symfony\Component\Serializer\Php\ForEachNode; +use Symfony\Component\Serializer\Php\IfNode; +use Symfony\Component\Serializer\Php\MethodCallNode; +use Symfony\Component\Serializer\Php\PropertyNode; +use Symfony\Component\Serializer\Php\ReturnNode; +use Symfony\Component\Serializer\Php\ScalarNode as PhpScalarNode; +use Symfony\Component\Serializer\Php\VariableNode; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Serialize\DataModel\CollectionNode; +use Symfony\Component\Serializer\Serialize\DataModel\DataModelNodeInterface; +use Symfony\Component\Serializer\Serialize\DataModel\ObjectNode; +use Symfony\Component\Serializer\Serialize\DataModel\ScalarNode; +use Symfony\Component\Serializer\Serialize\Encoder\EncoderInterface; +use Symfony\Component\Serializer\Serialize\VariableNameScoperTrait; + +/** + * Generates a template PHP syntax tree that serializes data by normalizing + * it then encoding it using an $encoderClassName. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class NormalizerEncoderTemplateGenerator implements TemplateGeneratorInterface +{ + use VariableNameScoperTrait; + + /** + * @param class-string $encoderClassName + */ + public function __construct( + private readonly string $encoderClassName, + ) { + } + + public function generate(DataModelNodeInterface $node, SerializeConfig $config, array $context): array + { + $context['nested'] ??= false; + $normalizedAccessor = $context['normalized_accessor'] ?? new VariableNode('normalized'); + + $encodeNodes = !$context['nested'] ? [ + new ExpressionNode(new MethodCallNode(new PhpScalarNode('\\'.$this->encoderClassName), 'encode', new ArgumentsNode([ + new VariableNode('resource'), + $normalizedAccessor, + new VariableNode('config'), + ]), static: true)), + ] : []; + + $encodeNullNodes = $node->type->isNullable() ? [ + new IfNode(new BinaryNode('===', new PhpScalarNode(null), new VariableNode('data')), [ + new ExpressionNode(new MethodCallNode(new PhpScalarNode('\\'.$this->encoderClassName), 'encode', new ArgumentsNode([ + new VariableNode('resource'), + new PhpScalarNode(null), + new VariableNode('config'), + ]), static: true)), + new ExpressionNode(new ReturnNode(null)), + ]), + ] : []; + + if ($node instanceof CollectionNode) { + $keyName = $this->scopeVariableName('key', $context); + + return [ + ...$encodeNullNodes, + new ExpressionNode(new AssignNode($normalizedAccessor, new ArrayNode([]))), + new ForEachNode($node->accessor, new VariableNode($keyName), $node->item->accessor, [ + ...$this->generate($node->item, $config, [ + 'normalized_accessor' => new ArrayAccessNode($normalizedAccessor, new VariableNode($keyName)), + 'nested' => true, + ] + $context), + ]), + ...$encodeNodes, + ]; + } + + if ($node instanceof ObjectNode) { + $nodes = []; + + foreach ($node->properties as $name => $propertyNode) { + array_push( + $nodes, + ...$this->generate($propertyNode, $config, [ + 'normalized_accessor' => new ArrayAccessNode($normalizedAccessor, new PhpScalarNode($name)), + 'nested' => true, + ] + $context), + ); + } + + return [ + ...$encodeNullNodes, + ...$nodes, + ...$encodeNodes, + ]; + } + + if ($node instanceof ScalarNode) { + $scalarAccessor = $node->type->isBackedEnum() ? new PropertyNode($node->accessor, 'value') : $node->accessor; + + return [ + ...$encodeNullNodes, + new ExpressionNode(new AssignNode($normalizedAccessor, $scalarAccessor)), + ...$encodeNodes, + ]; + } + + throw new LogicException(sprintf('Unexpected "%s" node', $node::class)); + } +} diff --git a/src/Symfony/Component/Serializer/Serialize/Template/Template.php b/src/Symfony/Component/Serializer/Serialize/Template/Template.php new file mode 100644 index 0000000000000..a84f1d15de84e --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/Template/Template.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\Template; + +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\Exception\UnsupportedFormatException; +use Symfony\Component\Serializer\Php\ClosureNode; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ExpressionNode; +use Symfony\Component\Serializer\Php\ParametersNode; +use Symfony\Component\Serializer\Php\PhpDocNode; +use Symfony\Component\Serializer\Php\ReturnNode; +use Symfony\Component\Serializer\Php\VariableNode; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Serialize\DataModel\DataModelBuilderInterface; +use Symfony\Component\Serializer\Template\TemplateVariation; +use Symfony\Component\Serializer\Template\TemplateVariationExtractorInterface; +use Symfony\Component\Serializer\Type\Type; + +/** + * Provide path and contents of a serialization template for a given type. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class Template +{ + /** + * @param array $generators + */ + public function __construct( + private readonly TemplateVariationExtractorInterface $variationExtractor, + private readonly DataModelBuilderInterface $dataModelBuilder, + private readonly array $generators, + private readonly string $cacheDir, + ) { + } + + public function path(Type $type, string $format, SerializeConfig $config): string + { + $hash = hash('xxh128', (string) $type); + + $variations = $this->variationExtractor->extractVariationsFromConfig($config); + if ([] !== $variations) { + $hash .= '.'.hash('xxh128', implode('_', array_map(fn (TemplateVariation $t): string => (string) $t, $variations))); + } + + return sprintf('%s%s%s.serialize.%s.php', $this->cacheDir, \DIRECTORY_SEPARATOR, $hash, $format); + } + + public function content(Type $type, string $format, SerializeConfig $config): string + { + $generator = $this->generators[$format] ?? null; + if (null === $generator) { + throw new UnsupportedFormatException(sprintf('"%s" format is not supported.', $format)); + } + + $compiler = new Compiler(); + + $compiler->compile(new PhpDocNode([ + sprintf('@param %s $data', $type), + '@param resource $resource', + ])); + $phpDoc = $compiler->source(); + + $compiler->reset(); + + $argumentsNode = new ParametersNode([ + 'data' => 'mixed', + 'resource' => 'mixed', + 'config' => '\\'.SerializeConfig::class, + 'services' => '\\'.ContainerInterface::class, + ]); + + $compiler->indent(); + $bodyNodes = $generator->generate($this->dataModelBuilder->build($type, new VariableNode('data'), $config), $config, []); + $compiler->outdent(); + + $compiler->compile(new ExpressionNode(new ReturnNode(new ClosureNode($argumentsNode, 'void', true, $bodyNodes)))); + $php = $compiler->source(); + + return " + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize\Template; + +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Serialize\DataModel\DataModelNodeInterface; + +/** + * Generates a serialization template PHP syntax tree based on a given data model. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface TemplateGeneratorInterface +{ + /** + * @param array $context + * + * @return list + */ + public function generate(DataModelNodeInterface $node, SerializeConfig $config, array $context): array; +} diff --git a/src/Symfony/Component/Serializer/Serialize/VariableNameScoperTrait.php b/src/Symfony/Component/Serializer/Serialize/VariableNameScoperTrait.php new file mode 100644 index 0000000000000..68a95cf433e63 --- /dev/null +++ b/src/Symfony/Component/Serializer/Serialize/VariableNameScoperTrait.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Serialize; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +trait VariableNameScoperTrait +{ + /** + * @param array{variable_counters?: array}&array $context + */ + protected function scopeVariableName(string $variableName, array &$context): string + { + if (!isset($context['variable_counters'][$variableName])) { + $context['variable_counters'][$variableName] = 0; + } + + $name = sprintf('%s_%d', $variableName, $context['variable_counters'][$variableName]); + + ++$context['variable_counters'][$variableName]; + + return $name; + } +} diff --git a/src/Symfony/Component/Serializer/Serializer.php b/src/Symfony/Component/Serializer/Serializer.php index eb8bb82f48cfa..54c77c01e107f 100644 --- a/src/Symfony/Component/Serializer/Serializer.php +++ b/src/Symfony/Component/Serializer/Serializer.php @@ -324,9 +324,12 @@ private function getDenormalizer(mixed $data, string $class, ?string $format, ar $supportedTypes = $normalizer->getSupportedTypes($format); + $doesClassRepresentCollection = str_ends_with($class, '[]'); + foreach ($supportedTypes as $supportedType => $isCacheable) { if (\in_array($supportedType, ['*', 'object'], true) || $class !== $supportedType && ('object' !== $genericType || !is_subclass_of($class, $supportedType)) + && !($doesClassRepresentCollection && str_ends_with($supportedType, '[]') && is_subclass_of(strstr($class, '[]', true), strstr($supportedType, '[]', true))) ) { continue; } diff --git a/src/Symfony/Component/Serializer/Stream/InputStream.php b/src/Symfony/Component/Serializer/Stream/InputStream.php new file mode 100644 index 0000000000000..f88a36dd4674f --- /dev/null +++ b/src/Symfony/Component/Serializer/Stream/InputStream.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Stream; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class InputStream extends Stream +{ + public function __construct(string $content = null) + { + parent::__construct('php://input', 'placeholder', $content); + } +} diff --git a/src/Symfony/Component/Serializer/Stream/MemoryStream.php b/src/Symfony/Component/Serializer/Stream/MemoryStream.php new file mode 100644 index 0000000000000..734b53befe12c --- /dev/null +++ b/src/Symfony/Component/Serializer/Stream/MemoryStream.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Stream; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class MemoryStream extends Stream +{ + public function __construct(string $content = null, string $mode = 'w+b') + { + parent::__construct('php://memory', $mode, $content); + } +} diff --git a/src/Symfony/Component/Serializer/Stream/OutputStream.php b/src/Symfony/Component/Serializer/Stream/OutputStream.php new file mode 100644 index 0000000000000..7ed18ea1941b5 --- /dev/null +++ b/src/Symfony/Component/Serializer/Stream/OutputStream.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Stream; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class OutputStream extends Stream +{ + public function __construct(string $content = null) + { + parent::__construct('php://output', 'placeholder', $content); + } +} diff --git a/src/Symfony/Component/Serializer/Stream/StdinStream.php b/src/Symfony/Component/Serializer/Stream/StdinStream.php new file mode 100644 index 0000000000000..62257a84266d0 --- /dev/null +++ b/src/Symfony/Component/Serializer/Stream/StdinStream.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Stream; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class StdinStream extends Stream +{ + public function __construct(string $content = null) + { + parent::__construct('php://stdin', 'placeholder', $content); + } +} diff --git a/src/Symfony/Component/Serializer/Stream/StdoutStream.php b/src/Symfony/Component/Serializer/Stream/StdoutStream.php new file mode 100644 index 0000000000000..92f7d92248900 --- /dev/null +++ b/src/Symfony/Component/Serializer/Stream/StdoutStream.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Stream; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class StdoutStream extends Stream +{ + public function __construct(string $content = null) + { + parent::__construct('php://stdout', 'placeholder', $content); + } +} diff --git a/src/Symfony/Component/Serializer/Stream/Stream.php b/src/Symfony/Component/Serializer/Stream/Stream.php new file mode 100644 index 0000000000000..0ecf5405a9065 --- /dev/null +++ b/src/Symfony/Component/Serializer/Stream/Stream.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Stream; + +use Symfony\Component\Serializer\Exception\InvalidResourceException; +use Symfony\Component\Serializer\Exception\RuntimeException; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +class Stream implements StreamInterface +{ + /** + * @var resource + */ + protected $resource; + + public function __construct( + protected readonly string $filename, + protected readonly string $mode, + string $content = null, + ) { + if (false === $resource = @fopen($this->filename, $this->mode)) { + throw new RuntimeException(sprintf('Cannot open "%s" resource', $this->filename)); + } + + $this->resource = $resource; + + if (null === $content) { + return; + } + + if (false === @fwrite($this->resource, $content)) { + throw new InvalidResourceException($this->resource); + } + + if (false === @rewind($this->resource)) { + throw new InvalidResourceException($this->resource); + } + } + + final public function resource(): mixed + { + return $this->resource; + } + + final public function __toString(): string + { + if (false === @rewind($this->resource)) { + throw new InvalidResourceException($this->resource); + } + + if (false === $content = @stream_get_contents($this->resource)) { + throw new InvalidResourceException($this->resource); + } + + return $content; + } +} diff --git a/src/Symfony/Component/Serializer/Stream/StreamInterface.php b/src/Symfony/Component/Serializer/Stream/StreamInterface.php new file mode 100644 index 0000000000000..467d8ee95b367 --- /dev/null +++ b/src/Symfony/Component/Serializer/Stream/StreamInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Stream; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface StreamInterface extends \Stringable +{ + /** + * @return resource + */ + public function resource(): mixed; +} diff --git a/src/Symfony/Component/Serializer/Stream/TempStream.php b/src/Symfony/Component/Serializer/Stream/TempStream.php new file mode 100644 index 0000000000000..e2cfba678ded0 --- /dev/null +++ b/src/Symfony/Component/Serializer/Stream/TempStream.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Stream; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class TempStream extends Stream +{ + public function __construct(string $content = null, int $memoryThreshold = 2048, string $mode = 'w+b') + { + parent::__construct(sprintf('php://temp/maxmemory:%d', $memoryThreshold), $mode, $content); + } +} diff --git a/src/Symfony/Component/Serializer/Template/GroupTemplateVariation.php b/src/Symfony/Component/Serializer/Template/GroupTemplateVariation.php new file mode 100644 index 0000000000000..b79095745a76d --- /dev/null +++ b/src/Symfony/Component/Serializer/Template/GroupTemplateVariation.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Template; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +readonly class GroupTemplateVariation extends TemplateVariation +{ + public function __construct(string $group) + { + parent::__construct('group', $group); + } + + public function configure(SerializeConfig|DeserializeConfig $config): SerializeConfig|DeserializeConfig + { + $groups = $config->groups(); + $groups[] = $this->value; + $groups = array_values(array_unique($groups)); + + return $config->withGroups($groups); + } +} diff --git a/src/Symfony/Component/Serializer/Template/TemplateVariant.php b/src/Symfony/Component/Serializer/Template/TemplateVariant.php new file mode 100644 index 0000000000000..e47626df2f932 --- /dev/null +++ b/src/Symfony/Component/Serializer/Template/TemplateVariant.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Template; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; + +/** + * Holds a serialization/deserialization configuration and the related + * {@see Symfony\Component\Serializer\Template\TemplateVariation} combination. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final readonly class TemplateVariant +{ + /** + * @var list + */ + public array $variations; + + public SerializeConfig|DeserializeConfig $config; + + /** + * @param list $variations + */ + public function __construct(SerializeConfig|DeserializeConfig $config, array $variations) + { + usort($variations, fn (TemplateVariation $a, TemplateVariation $b): int => $a->compare($b)); + + foreach ($variations as $variation) { + $config = $variation->configure($config); + } + + $this->variations = $variations; + $this->config = $config; + } +} diff --git a/src/Symfony/Component/Serializer/Template/TemplateVariation.php b/src/Symfony/Component/Serializer/Template/TemplateVariation.php new file mode 100644 index 0000000000000..a2d81169cac2d --- /dev/null +++ b/src/Symfony/Component/Serializer/Template/TemplateVariation.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Template; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; + +/** + * Generic template variation representation. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +abstract readonly class TemplateVariation implements \Stringable +{ + public function __construct( + public string $type, + public string $value, + ) { + } + + /** + * @template T of SerializeConfig|DeserializeConfig + * + * @param T $config + * + * @return T + */ + abstract public function configure(SerializeConfig|DeserializeConfig $config): SerializeConfig|DeserializeConfig; + + public function compare(self $other): int + { + return (string) $this <=> (string) $other; + } + + public function __toString(): string + { + return sprintf('%s-%s', $this->type, $this->value); + } +} diff --git a/src/Symfony/Component/Serializer/Template/TemplateVariationExtractor.php b/src/Symfony/Component/Serializer/Template/TemplateVariationExtractor.php new file mode 100644 index 0000000000000..151a138a4af7d --- /dev/null +++ b/src/Symfony/Component/Serializer/Template/TemplateVariationExtractor.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Template; + +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Type\Type; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class TemplateVariationExtractor implements TemplateVariationExtractorInterface +{ + public function extractVariationsFromType(Type $type): array + { + $groups = []; + + $findClassNames = static function (Type $type, array $classNames = []) use (&$findClassNames): array { + if ($type->hasClass()) { + $classNames[] = $type->className(); + + return $classNames; + } + + foreach ($type->genericParameterTypes() as $genericParameterType) { + if (null !== $c = $findClassNames($genericParameterType, $classNames)) { + array_push($classNames, ...$c); + } + } + + foreach ($type->unionTypes() as $unionType) { + if (null !== $c = $findClassNames($unionType, $classNames)) { + array_push($classNames, ...$c); + } + } + + foreach ($type->intersectionTypes() as $intersectionType) { + if (null !== $c = $findClassNames($intersectionType, $classNames)) { + array_push($classNames, ...$c); + } + } + + return array_unique($classNames); + }; + + foreach ($findClassNames($type) as $className) { + foreach ((new \ReflectionClass($className))->getProperties() as $reflectionProperty) { + $reflectionAttribute = $reflectionProperty->getAttributes(Groups::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; + if (null === $reflectionAttribute) { + continue; + } + + array_push($groups, ...$reflectionAttribute->newInstance()->groups); + } + } + + $groups = array_values(array_unique($groups)); + + return array_map(fn (string $g): TemplateVariation => new GroupTemplateVariation($g), $groups); + } + + public function extractVariationsFromConfig(SerializeConfig|DeserializeConfig $config): array + { + $variations = []; + + foreach ($config->groups() ?? [] as $group) { + $variations[] = new GroupTemplateVariation($group); + } + + return $variations; + } +} diff --git a/src/Symfony/Component/Serializer/Template/TemplateVariationExtractorInterface.php b/src/Symfony/Component/Serializer/Template/TemplateVariationExtractorInterface.php new file mode 100644 index 0000000000000..edbb8bbb52395 --- /dev/null +++ b/src/Symfony/Component/Serializer/Template/TemplateVariationExtractorInterface.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Template; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Type\Type; + +/** + * Extracts {@see Symfony\Component\Serializer\Template\TemplateVariation} from + * a given type or serialization/deserialization configuration. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface TemplateVariationExtractorInterface +{ + /** + * @return list + */ + public function extractVariationsFromType(Type $type): array; + + /** + * @return list + */ + public function extractVariationsFromConfig(SerializeConfig|DeserializeConfig $config): array; +} diff --git a/src/Symfony/Component/Serializer/Tests/DependencyInjection/RuntimeSerializerServicesPassTest.php b/src/Symfony/Component/Serializer/Tests/DependencyInjection/RuntimeSerializerServicesPassTest.php new file mode 100644 index 0000000000000..36cd58fb681de --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/DependencyInjection/RuntimeSerializerServicesPassTest.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\DependencyInjection; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\DependencyInjection\TypedReference; +use Symfony\Component\Serializer\DependencyInjection\RuntimeSerializerServicesPass; +use Symfony\Component\Serializer\Serialize\SerializerInterface; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithAttributesUsingServices; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithFormatterAttributes; +use Symfony\Component\Serializer\Type\TypeExtractorInterface; + +class RuntimeSerializerServicesPassTest extends TestCase +{ + public function testRetrieveServices() + { + $container = new ContainerBuilder(); + + $container->register('serializer.serializer')->setArguments([null, null]); + $container->register('serializer.deserializer')->setArguments([null, null]); + $container->register('serializer.serialize.data_model_builder')->setArguments([null, null]); + $container->register('serializer.deserialize.data_model_builder')->setArguments([null, null]); + + $container->register(ClassicDummy::class, ClassicDummy::class)->addTag('serializer.serializable'); + $container->register(DummyWithFormatterAttributes::class, DummyWithFormatterAttributes::class)->addTag('serializer.serializable'); + $container->register(DummyWithAttributesUsingServices::class, DummyWithAttributesUsingServices::class)->addTag('serializer.serializable'); + + (new RuntimeSerializerServicesPass())->process($container); + + $runtimeServicesId = $container->getDefinition('serializer.serializer')->getArgument(1); + + $this->assertSame($runtimeServicesId, $container->getDefinition('serializer.deserializer')->getArgument(1)); + $this->assertSame($runtimeServicesId, $container->getDefinition('serializer.serialize.data_model_builder')->getArgument(1)); + $this->assertSame($runtimeServicesId, $container->getDefinition('serializer.deserialize.data_model_builder')->getArgument(1)); + + $runtimeServices = $container->getDefinition($runtimeServicesId)->getArgument(0); + + $runtimeService = $runtimeServices[sprintf('%s::serviceAndSerializeConfig[typeExtractor]', DummyWithAttributesUsingServices::class)]; + $this->assertInstanceOf(ServiceClosureArgument::class, $runtimeService); + $this->assertEquals([new TypedReference( + TypeExtractorInterface::class, + TypeExtractorInterface::class, + ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE, + 'typeExtractor', + )], $runtimeService->getValues()); + + $runtimeService = $runtimeServices[sprintf('%s::serviceAndSerializeConfig[serializer]', DummyWithAttributesUsingServices::class)]; + $this->assertInstanceOf(ServiceClosureArgument::class, $runtimeService); + $this->assertEquals([new TypedReference( + SerializerInterface::class, + SerializerInterface::class, + ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE, + 'serializer', + )], $runtimeService->getValues()); + + $runtimeService = $runtimeServices[sprintf('%s::serviceAndSerializeConfig[serializer]', DummyWithAttributesUsingServices::class)]; + $this->assertInstanceOf(ServiceClosureArgument::class, $runtimeService); + $this->assertEquals([new TypedReference( + SerializerInterface::class, + SerializerInterface::class, + ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE, + 'serializer', + )], $runtimeService->getValues()); + + $runtimeService = $runtimeServices[sprintf('%s::serviceAndDeserializeConfig[service]', DummyWithAttributesUsingServices::class)]; + $this->assertInstanceOf(ServiceClosureArgument::class, $runtimeService); + $this->assertEquals([new TypedReference( + TypeExtractorInterface::class, + TypeExtractorInterface::class, + ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE, + 'service', + )], $runtimeService->getValues()); + + $runtimeService = $runtimeServices[sprintf('%s::autowireAttribute[service]', DummyWithAttributesUsingServices::class)]; + $this->assertInstanceOf(ServiceClosureArgument::class, $runtimeService); + $this->assertEquals([new Reference( + 'serializer.type_extractor', + ContainerInterface::NULL_ON_INVALID_REFERENCE, + 'service', + )], $runtimeService->getValues()); + + $runtimeService = $runtimeServices[sprintf('%s::invalidNullableService[invalid]', DummyWithAttributesUsingServices::class)]; + $this->assertInstanceOf(ServiceClosureArgument::class, $runtimeService); + $this->assertEquals([new TypedReference( + \InvalidInterface::class, + \InvalidInterface::class, + ContainerInterface::NULL_ON_INVALID_REFERENCE, + 'invalid', + )], $runtimeService->getValues()); + + $runtimeService = $runtimeServices[sprintf('%s::invalidOptionalService[invalid]', DummyWithAttributesUsingServices::class)]; + $this->assertInstanceOf(ServiceClosureArgument::class, $runtimeService); + $this->assertEquals([new TypedReference( + \InvalidInterface::class, + \InvalidInterface::class, + ContainerInterface::IGNORE_ON_INVALID_REFERENCE, + 'invalid', + )], $runtimeService->getValues()); + + $this->assertArrayNotHasKey(sprintf('%s::skippedUnknownService[skipped]', DummyWithAttributesUsingServices::class), $runtimeServices); + + $this->assertArrayNotHasKey(sprintf('%s::serviceAndSerializeConfig[config]', DummyWithAttributesUsingServices::class), $runtimeServices); + $this->assertArrayNotHasKey(sprintf('%s::serviceAndDeserializeConfig[config]', DummyWithAttributesUsingServices::class), $runtimeServices); + + $this->assertArrayNotHasKey(sprintf('%s::serviceAndSerializeConfig[value]', DummyWithAttributesUsingServices::class), $runtimeServices); + $this->assertArrayNotHasKey(sprintf('%s::serviceAndDeserializeConfig[value]', DummyWithAttributesUsingServices::class), $runtimeServices); + $this->assertArrayNotHasKey(sprintf('%s::autowireAttribute[value]', DummyWithAttributesUsingServices::class), $runtimeServices); + $this->assertArrayNotHasKey(sprintf('%s::invalidNullableService[value]', DummyWithAttributesUsingServices::class), $runtimeServices); + $this->assertArrayNotHasKey(sprintf('%s::invalidOptionalService[value]', DummyWithAttributesUsingServices::class), $runtimeServices); + $this->assertArrayNotHasKey(sprintf('%s::skippedUnknownService[value]', DummyWithAttributesUsingServices::class), $runtimeServices); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/DependencyInjection/SerializablePassTest.php b/src/Symfony/Component/Serializer/Tests/DependencyInjection/SerializablePassTest.php new file mode 100644 index 0000000000000..ae3966a3b37a1 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/DependencyInjection/SerializablePassTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\DependencyInjection; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\Serializer\DependencyInjection\SerializablePass; +use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; + +class SerializablePassTest extends TestCase +{ + public function testFindSerializableClasses() + { + $container = new ContainerBuilder(); + $container->setParameter('serializer.serializable_paths', [\dirname(__DIR__, 1).'/Fixtures/{Dto,Invalid}']); + $container->register('serializer.serializer'); + + (new SerializablePass())->process($container); + + $serializable = []; + foreach ($container->getDefinitions() as $definition) { + if (!$definition->hasTag('serializer.serializable')) { + continue; + } + + $serializable[] = $definition->getClass(); + } + + $this->assertContains(ClassicDummy::class, $serializable); + $this->assertNotContains(AbstractDummy::class, $serializable); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/DependencyInjection/SerializerPassTest.php b/src/Symfony/Component/Serializer/Tests/DependencyInjection/SerializerPassTest.php index eb77263f49fc9..72de0b2e12d82 100644 --- a/src/Symfony/Component/Serializer/Tests/DependencyInjection/SerializerPassTest.php +++ b/src/Symfony/Component/Serializer/Tests/DependencyInjection/SerializerPassTest.php @@ -18,6 +18,9 @@ use Symfony\Component\Serializer\Debug\TraceableEncoder; use Symfony\Component\Serializer\Debug\TraceableNormalizer; use Symfony\Component\Serializer\DependencyInjection\SerializerPass; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithAttributesUsingServices; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithFormatterAttributes; /** * Tests for the SerializerPass class. @@ -58,7 +61,13 @@ public function testServicesAreOrderedAccordingToPriority() $container = new ContainerBuilder(); $container->setParameter('kernel.debug', false); + $container->register('serializer.serialize.template')->setArguments([null, null, null]); + $container->register('serializer.deserialize.template')->setArguments([null, null, null]); + $container->register('serializer.cache_warmer.template')->setArguments([null]); + $container->register('serializer.cache_warmer.lazy_ghost')->setArguments([null]); + $definition = $container->register('serializer')->setArguments([null, null]); + $container->register('n2')->addTag('serializer.normalizer', ['priority' => 100])->addTag('serializer.encoder', ['priority' => 100]); $container->register('n1')->addTag('serializer.normalizer', ['priority' => 200])->addTag('serializer.encoder', ['priority' => 200]); $container->register('n3')->addTag('serializer.normalizer')->addTag('serializer.encoder'); @@ -78,6 +87,12 @@ public function testServicesAreOrderedAccordingToPriority() public function testBindSerializerDefaultContext() { $container = new ContainerBuilder(); + + $container->register('serializer.serialize.template')->setArguments([null, null, null]); + $container->register('serializer.deserialize.template')->setArguments([null, null, null]); + $container->register('serializer.cache_warmer.template')->setArguments([null]); + $container->register('serializer.cache_warmer.lazy_ghost')->setArguments([null]); + $container->setParameter('kernel.debug', false); $container->register('serializer')->setArguments([null, null]); $container->setParameter('serializer.default_context', ['enable_max_depth' => true]); @@ -97,7 +112,13 @@ public function testNormalizersAndEncodersAreDecoredAndOrderedWhenCollectingData $container->setParameter('kernel.debug', true); $container->register('serializer.data_collector'); + $container->register('serializer.serialize.template')->setArguments([null, null, null]); + $container->register('serializer.deserialize.template')->setArguments([null, null, null]); + $container->register('serializer.cache_warmer.template')->setArguments([null]); + $container->register('serializer.cache_warmer.lazy_ghost')->setArguments([null]); + $container->register('serializer')->setArguments([null, null]); + $container->register('n')->addTag('serializer.normalizer'); $container->register('e')->addTag('serializer.encoder'); @@ -115,4 +136,72 @@ public function testNormalizersAndEncodersAreDecoredAndOrderedWhenCollectingData $this->assertEquals(new Reference('e'), $traceableEncoderDefinition->getArgument(0)); $this->assertEquals(new Reference('serializer.data_collector'), $traceableEncoderDefinition->getArgument(1)); } + + public function testRetrieveTemplateGenerators() + { + $container = new ContainerBuilder(); + + $container->register('serializer.serialize.template')->setArguments([null, null, null]); + $container->register('serializer.deserialize.template')->setArguments([null, null, null]); + $container->register('serializer.cache_warmer.template')->setArguments([null]); + $container->register('serializer.cache_warmer.lazy_ghost')->setArguments([null]); + + $container->register('serializer')->setArguments([null, null]); + + $container->setParameter('kernel.debug', true); + $container->register('foo')->addTag('serializer.normalizer')->addTag('serializer.encoder'); + + $container->register('serialize.json')->addTag('serializer.serialize.template_generator', ['format' => 'json']); + $container->register('serialize.csv')->addTag('serializer.serialize.template_generator', ['format' => 'csv']); + + $container->register('deserialize.json.eager')->addTag('serializer.deserialize.template_generator.eager', ['format' => 'json']); + $container->register('deserialize.csv.eager')->addTag('serializer.deserialize.template_generator.eager', ['format' => 'csv']); + + $container->register('deserialize.json.lazy')->addTag('serializer.deserialize.template_generator.lazy', ['format' => 'json']); + + (new SerializerPass())->process($container); + + $this->assertEquals([ + 'json' => new Reference('serialize.json'), + 'csv' => new Reference('serialize.csv'), + ], $container->getDefinition('serializer.serialize.template')->getArgument(2)); + + $this->assertEquals([ + 'json' => ['eager' => new Reference('deserialize.json.eager'), 'lazy' => new Reference('deserialize.json.lazy')], + 'csv' => ['eager' => new Reference('deserialize.csv.eager')], + ], $container->getDefinition('serializer.deserialize.template')->getArgument(2)); + } + + public function testInjectSerializable() + { + $container = new ContainerBuilder(); + + $container->register('serializer.serialize.template')->setArguments([null, null, null]); + $container->register('serializer.deserialize.template')->setArguments([null, null, null]); + $container->register('serializer.cache_warmer.template')->setArguments([null]); + $container->register('serializer.cache_warmer.lazy_ghost')->setArguments([null]); + + $container->register('serializer')->setArguments([null, null]); + + $container->setParameter('kernel.debug', true); + $container->register('foo')->addTag('serializer.normalizer')->addTag('serializer.encoder'); + + $container->register(ClassicDummy::class, ClassicDummy::class)->addTag('serializer.serializable'); + $container->register(DummyWithFormatterAttributes::class, DummyWithFormatterAttributes::class)->addTag('serializer.serializable'); + $container->register(DummyWithAttributesUsingServices::class, DummyWithAttributesUsingServices::class)->addTag('serializer.serializable'); + + (new SerializerPass())->process($container); + + $this->assertSame([ + ClassicDummy::class, + DummyWithFormatterAttributes::class, + DummyWithAttributesUsingServices::class, + ], $container->getDefinition('serializer.cache_warmer.template')->getArgument(0)); + + $this->assertSame([ + ClassicDummy::class, + DummyWithFormatterAttributes::class, + DummyWithAttributesUsingServices::class, + ], $container->getDefinition('serializer.cache_warmer.lazy_ghost')->getArgument(0)); + } } diff --git a/src/Symfony/Component/Serializer/Tests/Deserialize/DataModel/DataModelBuilderTest.php b/src/Symfony/Component/Serializer/Tests/Deserialize/DataModel/DataModelBuilderTest.php new file mode 100644 index 0000000000000..c918013c2762c --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Deserialize/DataModel/DataModelBuilderTest.php @@ -0,0 +1,241 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Deserialize\DataModel; + +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Deserialize\DataModel\CollectionNode; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelBuilder; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelNodeInterface; +use Symfony\Component\Serializer\Deserialize\DataModel\ObjectNode; +use Symfony\Component\Serializer\Deserialize\DataModel\ScalarNode; +use Symfony\Component\Serializer\Deserialize\Mapping\PropertyMetadata; +use Symfony\Component\Serializer\Deserialize\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Php\ArgumentsNode; +use Symfony\Component\Serializer\Php\FunctionCallNode; +use Symfony\Component\Serializer\Php\MethodCallNode; +use Symfony\Component\Serializer\Php\ScalarNode as PhpScalarNode; +use Symfony\Component\Serializer\Php\VariableNode; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithAttributesUsingServices; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithFormatterAttributes; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithMethods; +use Symfony\Component\Serializer\Type\Type; +use Symfony\Contracts\Service\ServiceLocatorTrait; + +class DataModelBuilderTest extends TestCase +{ + /** + * @dataProvider buildDataModelDataProvider + */ + public function testBuildDataModel(Type $type, DataModelNodeInterface $dataModel) + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader(), self::runtimeServices()); + + $this->assertEquals($dataModel, $dataModelBuilder->build($type, new DeserializeConfig())); + } + + /** + * @return iterable + */ + public static function buildDataModelDataProvider(): iterable + { + yield [Type::int(), new ScalarNode(Type::int())]; + yield [Type::array(), new ScalarNode(Type::array())]; + yield [Type::object(), new ScalarNode(Type::object())]; + yield [Type::class(\stdClass::class), new ScalarNode(Type::object())]; + yield [Type::union(Type::int(), Type::string()), new ScalarNode(Type::union(Type::int(), Type::string()))]; + yield [Type::intersection(Type::int(), Type::string()), new ScalarNode(Type::intersection(Type::int(), Type::string()))]; + + yield [Type::list(Type::string()), new CollectionNode(Type::list(Type::string()), new ScalarNode(Type::string()))]; + yield [Type::dict(Type::string()), new CollectionNode(Type::dict(Type::string()), new ScalarNode(Type::string()))]; + + yield [Type::class(self::class), new ObjectNode(Type::class(self::class), [], false)]; + } + + public function testAddGhostLeafWhenClassAlreadyGenerated() + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader([ + new PropertyMetadata('foo', Type::class(self::class), []), + ]), self::runtimeServices()); + + $this->assertEquals(new ObjectNode(Type::class(self::class), [[ + 'name' => 'foo', + 'value' => new ObjectNode(Type::class(self::class), [], true), + 'formatter' => fn () => false, + ]], false), $dataModelBuilder->build(Type::class(self::class), new DeserializeConfig())); + } + + public function testCallPropertyMetadataLoaderWithProperContext() + { + $config = new DeserializeConfig(); + $type = Type::class(self::class, true, [Type::int()]); + + $propertyMetadataLoader = $this->createMock(PropertyMetadataLoaderInterface::class); + $propertyMetadataLoader->expects($this->once()) + ->method('load') + ->with(self::class, $config, [ + 'original_type' => $type, + 'generated_classes' => [(string) $type => true], + ]) + ->willReturn([]); + + $dataModelBuilder = new DataModelBuilder($propertyMetadataLoader, self::runtimeServices()); + $dataModelBuilder->build($type, $config); + } + + public function testPropertyWithoutFormatter() + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader([ + new PropertyMetadata('foo', Type::class(self::class), []), + ]), self::runtimeServices()); + + /** @var ObjectNode $dataModel */ + $dataModel = $dataModelBuilder->build(Type::class(self::class), new DeserializeConfig()); + $formatter = $dataModel->properties[0]['formatter']; + + $this->assertEquals(new VariableNode('data'), $formatter(new VariableNode('data'))); + } + + public function testPropertyWithSimpleFormatter() + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader([ + new PropertyMetadata('foo', Type::class(self::class), ['strtoupper', DummyWithFormatterAttributes::divideAndCastToInt(...)]), + ]), self::runtimeServices()); + + /** @var ObjectNode $dataModel */ + $dataModel = $dataModelBuilder->build(Type::class(self::class), new DeserializeConfig()); + $formatter = $dataModel->properties[0]['formatter']; + + $this->assertEquals( + new FunctionCallNode( + sprintf('%s::divideAndCastToInt', DummyWithFormatterAttributes::class), + new ArgumentsNode([new FunctionCallNode('strtoupper', new ArgumentsNode([new VariableNode('data')]))]), + ), + $formatter(new VariableNode('data')), + ); + } + + public function testPropertyWithFormatterWithConfig() + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader([ + new PropertyMetadata( + 'foo', + Type::class(DummyWithFormatterAttributes::class), + [DummyWithFormatterAttributes::divideAndCastToIntWithConfig(...)], + ), + ]), self::runtimeServices()); + + /** @var ObjectNode $dataModel */ + $dataModel = $dataModelBuilder->build(Type::class(self::class), new DeserializeConfig()); + $formatter = $dataModel->properties[0]['formatter']; + + $this->assertEquals( + new FunctionCallNode(sprintf('%s::divideAndCastToIntWithConfig', DummyWithFormatterAttributes::class), new ArgumentsNode([ + new VariableNode('data'), + new VariableNode('config'), + ])), + $formatter(new VariableNode('data')), + ); + } + + public function testPropertyWithFormatterWithRuntimeServices() + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader([ + new PropertyMetadata( + 'foo', + Type::class(DummyWithAttributesUsingServices::class), + [DummyWithAttributesUsingServices::serviceAndDeserializeConfig(...)], + ), + ]), self::runtimeServices([ + sprintf('%s::serviceAndDeserializeConfig[service]', DummyWithAttributesUsingServices::class) => 'useless', + ])); + + /** @var ObjectNode $dataModel */ + $dataModel = $dataModelBuilder->build(Type::class(self::class), new DeserializeConfig()); + $formatter = $dataModel->properties[0]['formatter']; + + $this->assertEquals( + new FunctionCallNode(sprintf('%s::serviceAndDeserializeConfig', DummyWithAttributesUsingServices::class), new ArgumentsNode([ + new VariableNode('data'), + new MethodCallNode( + new VariableNode('services'), + 'get', + new ArgumentsNode([new PhpScalarNode(sprintf('%s::serviceAndDeserializeConfig[service]', DummyWithAttributesUsingServices::class))]), + ), + new VariableNode('config'), + ])), + $formatter(new VariableNode('data')), + ); + } + + public function testPropertyWithConstFormatter() + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader([ + new PropertyMetadata('foo', Type::class(self::class), [DummyWithMethods::const(...)]), + ]), self::runtimeServices()); + + /** @var ObjectNode $dataModel */ + $dataModel = $dataModelBuilder->build(Type::class(self::class), new DeserializeConfig()); + $formatter = $dataModel->properties[0]['formatter']; + + $this->assertEquals( + new FunctionCallNode(sprintf('%s::const', DummyWithMethods::class), new ArgumentsNode([])), + $formatter(new VariableNode('data')), + ); + } + + public function testPropertyWithFormatterWithInvalidArgument() + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader([ + new PropertyMetadata( + 'foo', + Type::class(DummyWithAttributesUsingServices::class), + [DummyWithAttributesUsingServices::serviceAndDeserializeConfig(...)], + ), + ]), self::runtimeServices()); + + $this->expectException(LogicException::class); + + /** @var ObjectNode $dataModel */ + $dataModel = $dataModelBuilder->build(Type::class(self::class), new DeserializeConfig()); + $dataModel->properties[0]['formatter'](new VariableNode('data')); + } + + /** + * @param array $propertiesMetadata + */ + private static function propertyMetadataLoader(array $propertiesMetadata = []): PropertyMetadataLoaderInterface + { + return new class($propertiesMetadata) implements PropertyMetadataLoaderInterface { + public function __construct(private readonly array $propertiesMetadata) + { + } + + public function load(string $className, DeserializeConfig $config, array $context): array + { + return $this->propertiesMetadata; + } + }; + } + + /** + * @param array $runtimeServices + */ + private static function runtimeServices(array $runtimeServices = []): ContainerInterface + { + return new class($runtimeServices) implements ContainerInterface { + use ServiceLocatorTrait; + }; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Deserialize/Decoder/CsvDecoderTest.php b/src/Symfony/Component/Serializer/Tests/Deserialize/Decoder/CsvDecoderTest.php new file mode 100644 index 0000000000000..19b37d51e215d --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Deserialize/Decoder/CsvDecoderTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Internal\Deserialize\Csv; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Deserialize\Decoder\CsvDecoder; +use Symfony\Component\Serializer\Exception\InvalidResourceException; + +class CsvDecoderTest extends TestCase +{ + public function testDecode() + { + $this->assertSame([['foo']], CsvDecoder::decode($this->createResource("0\nfoo"), 0, -1, new DeserializeConfig())); + } + + public function testDecodeSubset() + { + $this->assertSame([['foo']], CsvDecoder::decode($this->createResource("before0\nfooafter"), 6, 5, new DeserializeConfig())); + } + + public function testDecodeThrowOnInvalidResource() + { + $this->expectException(InvalidResourceException::class); + + (new CsvDecoder())->decode(fopen(sprintf('%s/%s', sys_get_temp_dir(), uniqid()), 'w'), 0, -1, new DeserializeConfig()); + } + + /** + * @return resource + */ + private function createResource(string $content): mixed + { + /** @var resource $resource */ + $resource = fopen('php://temp', 'w'); + + fwrite($resource, $content); + rewind($resource); + + return $resource; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Deserialize/Decoder/JsonDecoderTest.php b/src/Symfony/Component/Serializer/Tests/Deserialize/Decoder/JsonDecoderTest.php new file mode 100644 index 0000000000000..4e710962e1b05 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Deserialize/Decoder/JsonDecoderTest.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Deserialize\Decoder; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Deserialize\Config\JsonDeserializeConfig; +use Symfony\Component\Serializer\Deserialize\Decoder\JsonDecoder; +use Symfony\Component\Serializer\Exception\InvalidResourceException; + +class JsonDecoderTest extends TestCase +{ + public function testDecode() + { + $this->assertSame('foo', JsonDecoder::decode($this->createResource('"foo"'), 0, -1, new DeserializeConfig())); + } + + public function testDecodeSubset() + { + $this->assertSame('bar', JsonDecoder::decode($this->createResource('["foo","bar","baz"]'), 7, 5, new DeserializeConfig())); + } + + public function testDecodeWithJsonDecodeFlags() + { + $config = new DeserializeConfig(); + + $this->assertSame( + 1.2345678901234568E+29, + JsonDecoder::decode($this->createResource('123456789012345678901234567890'), 0, -1, $config), + ); + + $jsonConfig = (new JsonDeserializeConfig())->withFlags(\JSON_BIGINT_AS_STRING); + $config = $config->withJsonConfig($jsonConfig); + + $this->assertSame( + '123456789012345678901234567890', + JsonDecoder::decode($this->createResource('123456789012345678901234567890'), 0, -1, $config), + ); + } + + public function testDecodeThrowOnInvalidResource() + { + $this->expectException(InvalidResourceException::class); + + JsonDecoder::decode(fopen(sprintf('%s/%s', sys_get_temp_dir(), uniqid()), 'w'), 0, -1, new DeserializeConfig()); + } + + public function testDecodeThrowOnInvalidJson() + { + $this->expectException(InvalidResourceException::class); + + JsonDecoder::decode($this->createResource('foo"'), 0, -1, new DeserializeConfig()); + } + + /** + * @return resource + */ + private function createResource(string $content): mixed + { + /** @var resource $resource */ + $resource = fopen('php://temp', 'w'); + + fwrite($resource, $content); + rewind($resource); + + return $resource; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Deserialize/DeserializerTest.php b/src/Symfony/Component/Serializer/Tests/Deserialize/DeserializerTest.php new file mode 100644 index 0000000000000..82228c547d92d --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Deserialize/DeserializerTest.php @@ -0,0 +1,277 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Deserialize; + +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelBuilder; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelBuilderInterface; +use Symfony\Component\Serializer\Deserialize\Decoder\CsvDecoder; +use Symfony\Component\Serializer\Deserialize\Decoder\JsonDecoder; +use Symfony\Component\Serializer\Deserialize\Deserializer; +use Symfony\Component\Serializer\Deserialize\DeserializerInterface; +use Symfony\Component\Serializer\Deserialize\Instantiator\EagerInstantiator; +use Symfony\Component\Serializer\Deserialize\Mapping\AttributePropertyMetadataLoader; +use Symfony\Component\Serializer\Deserialize\Mapping\PropertyMetadataLoader; +use Symfony\Component\Serializer\Deserialize\Mapping\TypePropertyMetadataLoader; +use Symfony\Component\Serializer\Deserialize\Splitter\JsonSplitter; +use Symfony\Component\Serializer\Deserialize\Template\EagerTemplateGenerator; +use Symfony\Component\Serializer\Deserialize\Template\LazyTemplateGenerator; +use Symfony\Component\Serializer\Deserialize\Template\Template; +use Symfony\Component\Serializer\Stream\MemoryStream; +use Symfony\Component\Serializer\Template\TemplateVariationExtractor; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithFormatterAttributes; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithGroups; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithNameAttributes; +use Symfony\Component\Serializer\Tests\Fixtures\Enum\DummyBackedEnum; +use Symfony\Component\Serializer\Type\PhpstanTypeExtractor; +use Symfony\Component\Serializer\Type\ReflectionTypeExtractor; +use Symfony\Component\Serializer\Type\Type; +use Symfony\Contracts\Service\ServiceLocatorTrait; + +class DeserializerTest extends TestCase +{ + private string $cacheDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->cacheDir = sprintf('%s/symfony_serializer_template', sys_get_temp_dir()); + + if (is_dir($this->cacheDir)) { + array_map('unlink', glob($this->cacheDir.'/*')); + rmdir($this->cacheDir); + } + } + + public function testDeserializeWithStringInput() + { + $this->assertTrue($this->deserializer()->deserialize('true', Type::bool(), 'json')); + } + + public function testDeserializeScalar() + { + $this->assertNull($this->deserializer()->deserialize(new MemoryStream('null'), Type::int(nullable: true), 'json')); + $this->assertTrue($this->deserializer()->deserialize(new MemoryStream('true'), Type::bool(), 'json')); + $this->assertSame( + [['foo' => 1, 'bar' => 2], ['foo' => 3]], + $this->deserializer()->deserialize(new MemoryStream('[{"foo": 1, "bar": 2}, {"foo": 3}]'), Type::array(), 'json'), + ); + $this->assertSame( + [['foo' => 1, 'bar' => 2], ['foo' => 3]], + $this->deserializer()->deserialize(new MemoryStream('[{"foo": 1, "bar": 2}, {"foo": 3}]'), Type::iterable(), 'json'), + ); + $this->assertEquals( + (object) ['foo' => 'bar'], + $this->deserializer()->deserialize(new MemoryStream('{"foo": "bar"}'), Type::object(), 'json'), + ); + $this->assertEquals( + DummyBackedEnum::ONE, + $this->deserializer()->deserialize(new MemoryStream('1'), Type::enum(DummyBackedEnum::class), 'json'), + ); + } + + public function testDeserializeCollection() + { + $this->assertSame( + [['foo' => 1, 'bar' => 2], ['foo' => 3]], + $this->deserializer()->deserialize(new MemoryStream('[{"foo": 1, "bar": 2}, {"foo": 3}]'), Type::list(Type::dict(Type::int())), 'json'), + ); + + $iterable = $this->deserializer()->deserialize(new MemoryStream('[{"foo": 1, "bar": 2}, {"foo": 3}]'), Type::iterableList(Type::iterableDict(Type::int())), 'json'); + $this->assertIsIterable($iterable); + $array = []; + foreach ($iterable as $item) { + $array[] = iterator_to_array($item); + } + + $this->assertSame([['foo' => 1, 'bar' => 2], ['foo' => 3]], $array); + } + + public function testDeserializeObject() + { + $dummy = new ClassicDummy(); + $dummy->id = 10; + $dummy->name = 'dummy name'; + + $this->assertEquals( + $dummy, + $this->deserializer()->deserialize(new MemoryStream('{"id": 10, "name": "dummy name"}'), Type::class(ClassicDummy::class), 'json'), + ); + } + + public function testDeserializeObjectWithSerializedName() + { + $dummy = new DummyWithNameAttributes(); + $dummy->id = 10; + + $this->assertEquals( + $dummy, + $this->deserializer()->deserialize(new MemoryStream('{"@id": 10}'), Type::class(DummyWithNameAttributes::class), 'json'), + ); + } + + public function testDeserializeObjectWithDeserializeFormatter() + { + $dummy = new DummyWithFormatterAttributes(); + $dummy->id = 10; + + $this->assertEquals( + $dummy, + $this->deserializer()->deserialize(new MemoryStream('{"id": "20"}'), Type::class(DummyWithFormatterAttributes::class), 'json'), + ); + } + + public function testDeserializeObjectWithGroupsAttribute() + { + $dummyWithoutGroup = new DummyWithGroups(); + $dummyWithoutGroup->none = 'set'; + $dummyWithoutGroup->one = 'set'; + $dummyWithoutGroup->oneAndTwo = 'set'; + $dummyWithoutGroup->twoAndThree = 'set'; + + $this->assertEquals( + $dummyWithoutGroup, + $this->deserializer()->deserialize( + new MemoryStream('{"none": "set", "one": "set", "oneAndTwo": "set", "twoAndThree": "set"}'), + Type::class(DummyWithGroups::class), + 'json', + ), + ); + + $dummyWithGroupOne = new DummyWithGroups(); + $dummyWithGroupOne->one = 'set'; + $dummyWithGroupOne->oneAndTwo = 'set'; + + $this->assertEquals( + $dummyWithGroupOne, + $this->deserializer()->deserialize( + new MemoryStream('{"none": "set", "one": "set", "oneAndTwo": "set", "twoAndThree": "set"}'), + Type::class(DummyWithGroups::class), + 'json', + (new DeserializeConfig())->withGroups('one'), + ), + ); + + $dummyWithGroupTwo = new DummyWithGroups(); + $dummyWithGroupTwo->oneAndTwo = 'set'; + $dummyWithGroupTwo->twoAndThree = 'set'; + + $this->assertEquals( + $dummyWithGroupTwo, + $this->deserializer()->deserialize( + new MemoryStream('{"none": "set", "one": "set", "oneAndTwo": "set", "twoAndThree": "set"}'), + Type::class(DummyWithGroups::class), + 'json', + (new DeserializeConfig())->withGroups('two'), + ), + ); + + $this->assertEquals( + new DummyWithGroups(), + $this->deserializer()->deserialize( + new MemoryStream('{"none": "set", "one": "set", "oneAndTwo": "set", "twoAndThree": "set"}'), + Type::class(DummyWithGroups::class), + 'json', + (new DeserializeConfig())->withGroups('other'), + ), + ); + } + + public function testCreateCacheFile() + { + $this->deserializer()->deserialize(new MemoryStream('true'), Type::bool(), 'json'); + + $this->assertFileExists($this->cacheDir); + $this->assertCount(1, glob($this->cacheDir.'/*')); + } + + public function testCreateCacheFileOnlyIfNotExists() + { + $template = new Template( + new TemplateVariationExtractor(), + $this->createStub(DataModelBuilderInterface::class), + [], + $this->cacheDir, + false, + ); + if (!file_exists($this->cacheDir)) { + mkdir($this->cacheDir, recursive: true); + } + + $cacheFilename = $template->path(Type::bool(), 'json', new DeserializeConfig()); + file_put_contents($cacheFilename, 'assertSame('CACHED', $this->deserializer()->deserialize(new MemoryStream('true'), Type::bool(), 'json')); + } + + public function testRecreateCacheFileIfForceGenerateTemplate() + { + $template = new Template( + new TemplateVariationExtractor(), + $this->createStub(DataModelBuilderInterface::class), + [], + $this->cacheDir, + false, + ); + if (!file_exists($this->cacheDir)) { + mkdir($this->cacheDir, recursive: true); + } + + $cacheFilename = $template->path(Type::bool(), 'json', new DeserializeConfig()); + file_put_contents($cacheFilename, 'assertTrue($this->deserializer()->deserialize( + new MemoryStream('true'), + Type::bool(), + 'json', + (new DeserializeConfig())->withForceGenerateTemplate(), + )); + } + + /** + * @param array $runtimeServices + */ + private function deserializer(array $runtimeServices = []): DeserializerInterface + { + $typeExtractor = new PhpstanTypeExtractor(new ReflectionTypeExtractor()); + $propertyMetadataLoader = new TypePropertyMetadataLoader( + new AttributePropertyMetadataLoader(new PropertyMetadataLoader($typeExtractor), $typeExtractor), + $typeExtractor, + ); + $runtimeServicesLocator = new class($runtimeServices) implements ContainerInterface { + use ServiceLocatorTrait; + }; + $dataModeBuilder = new DataModelBuilder($propertyMetadataLoader, $runtimeServicesLocator); + $template = new Template( + new TemplateVariationExtractor(), + $dataModeBuilder, + [ + 'json' => [ + 'eager' => new EagerTemplateGenerator(JsonDecoder::class), + 'lazy' => new LazyTemplateGenerator(JsonDecoder::class, JsonSplitter::class), + ], + 'csv' => [ + 'eager' => new EagerTemplateGenerator(CsvDecoder::class), + ], + ], + $this->cacheDir, + false, + ); + $instantiator = new EagerInstantiator(); + + return new Deserializer($template, $runtimeServicesLocator, $instantiator, $this->cacheDir); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Deserialize/Instantiator/EagerInstantiatorTest.php b/src/Symfony/Component/Serializer/Tests/Deserialize/Instantiator/EagerInstantiatorTest.php new file mode 100644 index 0000000000000..31ba0c2a31458 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Deserialize/Instantiator/EagerInstantiatorTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Deserialize\Instantiator; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Deserialize\Instantiator\EagerInstantiator; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; + +class EagerInstantiatorTest extends TestCase +{ + public function testInstantiate() + { + $expected = new ClassicDummy(); + $expected->id = 100; + $expected->name = 'dummy'; + + $properties = [ + 'id' => fn () => 100, + 'name' => fn () => 'dummy', + ]; + + $this->assertEquals($expected, (new EagerInstantiator())->instantiate(ClassicDummy::class, $properties)); + } + + public function testThrowOnInvalidProperty() + { + $this->expectException(UnexpectedValueException::class); + + (new EagerInstantiator())->instantiate(ClassicDummy::class, [ + 'id' => fn () => ['an', 'array'], + ]); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Deserialize/Instantiator/LazyInstantiatorTest.php b/src/Symfony/Component/Serializer/Tests/Deserialize/Instantiator/LazyInstantiatorTest.php new file mode 100644 index 0000000000000..30e78569a6b18 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Deserialize/Instantiator/LazyInstantiatorTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Deserialize\Instantiator; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Deserialize\Instantiator\LazyInstantiator; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithFormatterAttributes; + +class LazyInstantiatorTest extends TestCase +{ + private string $cacheDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->cacheDir = sprintf('%s/symfony_serializer_lazy_ghost', sys_get_temp_dir()); + + if (is_dir($this->cacheDir)) { + array_map('unlink', glob($this->cacheDir.'/*')); + rmdir($this->cacheDir); + } + } + + public function testCreateLazyGhost() + { + $ghost = (new LazyInstantiator($this->cacheDir))->instantiate(ClassicDummy::class, []); + + $this->assertArrayHasKey(sprintf("\0%sGhost\0lazyObjectState", preg_replace('/\\\\/', '', ClassicDummy::class)), (array) $ghost); + } + + public function testCreateCacheFile() + { + (new LazyInstantiator($this->cacheDir))->instantiate(DummyWithFormatterAttributes::class, []); + + $this->assertCount(1, glob($this->cacheDir.'/*')); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Deserialize/Mapping/AttributePropertyMetadataLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Deserialize/Mapping/AttributePropertyMetadataLoaderTest.php new file mode 100644 index 0000000000000..b2f8f19ca1e2c --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Deserialize/Mapping/AttributePropertyMetadataLoaderTest.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Deserialize\Mapping; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Deserialize\Mapping\AttributePropertyMetadataLoader; +use Symfony\Component\Serializer\Deserialize\Mapping\PropertyMetadata; +use Symfony\Component\Serializer\Deserialize\Mapping\PropertyMetadataLoader; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithFormatterAttributes; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithGroups; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithNameAttributes; +use Symfony\Component\Serializer\Type\PhpstanTypeExtractor; +use Symfony\Component\Serializer\Type\ReflectionTypeExtractor; +use Symfony\Component\Serializer\Type\Type; + +class AttributePropertyMetadataLoaderTest extends TestCase +{ + public function testFilterPropertiesByGroups() + { + $typeExtractor = new PhpstanTypeExtractor(new ReflectionTypeExtractor()); + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader($typeExtractor), $typeExtractor); + + $this->assertSame( + ['none', 'one', 'oneAndTwo', 'twoAndThree'], + array_keys($loader->load(DummyWithGroups::class, new DeserializeConfig(), [])), + ); + + $this->assertSame( + ['one', 'oneAndTwo'], + array_keys($loader->load(DummyWithGroups::class, (new DeserializeConfig())->withGroups(['one']), [])), + ); + + $this->assertSame( + ['oneAndTwo', 'twoAndThree'], + array_keys($loader->load(DummyWithGroups::class, (new DeserializeConfig())->withGroups(['two']), [])), + ); + + $this->assertSame( + ['twoAndThree'], + array_keys($loader->load(DummyWithGroups::class, (new DeserializeConfig())->withGroups(['three']), [])), + ); + + $this->assertSame([], $loader->load(DummyWithGroups::class, (new DeserializeConfig())->withGroups(['other']), [])); + } + + public function testRetrieveSerializedName() + { + $typeExtractor = new PhpstanTypeExtractor(new ReflectionTypeExtractor()); + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader($typeExtractor), $typeExtractor); + + $this->assertSame(['@id', 'name'], array_keys($loader->load(DummyWithNameAttributes::class, new DeserializeConfig(), []))); + } + + public function testRetrieveDeserializeFormatter() + { + $typeExtractor = new PhpstanTypeExtractor(new ReflectionTypeExtractor()); + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader($typeExtractor), $typeExtractor); + + $this->assertEquals([ + 'id' => new PropertyMetadata('id', Type::string(), [DummyWithFormatterAttributes::divideAndCastToInt(...)]), + 'name' => new PropertyMetadata('name', Type::string(), []), + ], $loader->load(DummyWithFormatterAttributes::class, new DeserializeConfig(), [])); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Deserialize/Mapping/PropertyMetadataLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Deserialize/Mapping/PropertyMetadataLoaderTest.php new file mode 100644 index 0000000000000..f78dd66f51359 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Deserialize/Mapping/PropertyMetadataLoaderTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Deserialize\Mapping; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Deserialize\Mapping\PropertyMetadata; +use Symfony\Component\Serializer\Deserialize\Mapping\PropertyMetadataLoader; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Type\PhpstanTypeExtractor; +use Symfony\Component\Serializer\Type\ReflectionTypeExtractor; +use Symfony\Component\Serializer\Type\Type; + +class PropertyMetadataLoaderTest extends TestCase +{ + public function testExtractPropertyType() + { + $loader = new PropertyMetadataLoader(new PhpstanTypeExtractor(new ReflectionTypeExtractor())); + + $this->assertEquals([ + 'id' => new PropertyMetadata('id', Type::int(), []), + 'name' => new PropertyMetadata('name', Type::string(), []), + ], $loader->load(ClassicDummy::class, new DeserializeConfig(), [])); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Deserialize/Mapping/PropertyMetadataTest.php b/src/Symfony/Component/Serializer/Tests/Deserialize/Mapping/PropertyMetadataTest.php new file mode 100644 index 0000000000000..691757bc6ddb4 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Deserialize/Mapping/PropertyMetadataTest.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Deserialize\Mapping; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Deserialize\Mapping\PropertyMetadata; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithMethods; +use Symfony\Component\Serializer\Type\Type; + +class PropertyMetadataTest extends TestCase +{ + public function testThrowOnNonStaticFormatter() + { + $this->expectException(InvalidArgumentException::class); + new PropertyMetadata('useless', Type::mixed(), [(new DummyWithMethods())->nonStatic(...)]); + } + + public function testThrowOnNonAnonymousFormatter() + { + $this->expectException(InvalidArgumentException::class); + new PropertyMetadata('useless', Type::mixed(), [fn () => 'useless']); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Deserialize/Mapping/TypePropertyMetadataLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Deserialize/Mapping/TypePropertyMetadataLoaderTest.php new file mode 100644 index 0000000000000..e28db75efe1f3 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Deserialize/Mapping/TypePropertyMetadataLoaderTest.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Deserialize\Mapping; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Deserialize\Mapping\PropertyMetadata; +use Symfony\Component\Serializer\Deserialize\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\Serializer\Deserialize\Mapping\TypePropertyMetadataLoader; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithGenerics; +use Symfony\Component\Serializer\Type\PhpstanTypeExtractor; +use Symfony\Component\Serializer\Type\Type; +use Symfony\Component\Serializer\Type\TypeExtractorInterface; + +class TypePropertyMetadataLoaderTest extends TestCase +{ + public function testCastStringToDateTime() + { + $loader = new TypePropertyMetadataLoader(self::propertyMetadataLoader([ + 'foo' => new PropertyMetadata('foo', Type::class(\DateTimeImmutable::class), []), + ]), $this->createStub(TypeExtractorInterface::class)); + + $metadata = $loader->load(self::class, new DeserializeConfig(), ['original_type' => Type::fromString('useless')]); + + $this->assertEquals([ + 'foo' => new PropertyMetadata('foo', Type::string(), [ + \Closure::fromCallable(TypePropertyMetadataLoader::castStringToDateTime(...)), + ]), + ], $metadata); + + $formatter = $metadata['foo']->formatters()[0]; + + $this->assertEquals( + new \DateTimeImmutable('2023-07-26'), + $formatter('2023-07-26', new DeserializeConfig()), + ); + + $this->assertEquals( + (new \DateTimeImmutable('2023-07-26'))->setTime(0, 0), + $formatter('26/07/2023 00:00:00', (new DeserializeConfig())->withDateTimeFormat('d/m/Y H:i:s')), + ); + } + + public function testReplaceGenerics() + { + $loader = new TypePropertyMetadataLoader(self::propertyMetadataLoader([ + 'foo' => new PropertyMetadata('foo', Type::fromString('T'), []), + ]), new PhpstanTypeExtractor($this->createStub(TypeExtractorInterface::class))); + + $metadata = $loader->load( + DummyWithGenerics::class, + new DeserializeConfig(), + ['original_type' => Type::class(DummyWithGenerics::class, genericParameterTypes: [Type::int()])], + ); + + $this->assertEquals([ + 'foo' => new PropertyMetadata('foo', Type::int(), []), + ], $metadata); + } + + public function testReplaceGenericsAndCastStringToDateTime() + { + $loader = new TypePropertyMetadataLoader(self::propertyMetadataLoader([ + 'foo' => new PropertyMetadata('foo', Type::fromString('T'), []), + ]), new PhpstanTypeExtractor($this->createStub(TypeExtractorInterface::class))); + + $metadata = $loader->load( + DummyWithGenerics::class, + new DeserializeConfig(), + ['original_type' => Type::class(DummyWithGenerics::class, genericParameterTypes: [Type::class(\DateTimeImmutable::class)])], + ); + + $this->assertEquals([ + 'foo' => new PropertyMetadata('foo', Type::string(), [ + \Closure::fromCallable(TypePropertyMetadataLoader::castStringToDateTime(...)), + ]), + ], $metadata); + } + + /** + * @param array $propertiesMetadata + */ + private static function propertyMetadataLoader(array $propertiesMetadata = []): PropertyMetadataLoaderInterface + { + return new class($propertiesMetadata) implements PropertyMetadataLoaderInterface { + public function __construct(private readonly array $propertiesMetadata) + { + } + + public function load(string $className, DeserializeConfig $config, array $context): array + { + return $this->propertiesMetadata; + } + }; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Deserialize/Splitter/JsonLexerTest.php b/src/Symfony/Component/Serializer/Tests/Deserialize/Splitter/JsonLexerTest.php new file mode 100644 index 0000000000000..93921860db95e --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Deserialize/Splitter/JsonLexerTest.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Deserialize\Splitter; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Deserialize\Splitter\JsonLexer; +use Symfony\Component\Serializer\Exception\InvalidResourceException; + +class JsonLexerTest extends TestCase +{ + /** + * @dataProvider tokensDataProvider + * + * @param list $expectedTokens + */ + public function testTokens(array $expectedTokens, string $content) + { + $this->assertSame($expectedTokens, iterator_to_array((new JsonLexer())->tokens($this->createResource($content), 0, -1))); + } + + /** + * @return iterable, 1: string}> + */ + public static function tokensDataProvider(): iterable + { + yield [[['1', 0]], '1']; + yield [[['false', 0]], 'false']; + yield [[['null', 0]], 'null']; + yield [[['"string"', 0]], '"string"']; + + yield [[['[', 0], [']', 1]], '[]']; + yield [[['[', 0], ['10', 2], [',', 4], ['20', 6], [']', 9]], '[ 10, 20 ]']; + yield [[['[', 0], ['1', 1], [',', 2], ['[', 4], ['2', 5], [']', 6], [']', 8]], '[1, [2] ]']; + + yield [[['{', 0], ['}', 1]], '{}']; + yield [[['{', 0], ['"foo"', 1], [':', 6], ['{', 8], ['"bar"', 9], [':', 14], ['"baz"', 15], ['}', 20], ['}', 21]], '{"foo": {"bar":"baz"}}']; + } + + public function testTokensSubset() + { + $this->assertSame([['false', 7]], iterator_to_array((new JsonLexer())->tokens($this->createResource('[1, 2, false]'), 7, 5))); + } + + public function testTokensThrowOnInvalidResource() + { + $this->expectException(InvalidResourceException::class); + + iterator_to_array((new JsonLexer())->tokens(fopen(sprintf('%s/%s', sys_get_temp_dir(), uniqid()), 'w'), 0, -1)); + } + + public function testTokenizeOverflowingBuffer() + { + $veryLongString = sprintf('"%s"', str_repeat('.', 20000)); + + $this->assertSame([[$veryLongString, 0]], iterator_to_array((new JsonLexer())->tokens($this->createResource($veryLongString), 0, -1))); + } + + /** + * @return resource + */ + private function createResource(string $content): mixed + { + /** @var resource $resource */ + $resource = fopen('php://temp', 'w'); + + fwrite($resource, $content); + rewind($resource); + + return $resource; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Deserialize/Splitter/JsonSplitterTest.php b/src/Symfony/Component/Serializer/Tests/Deserialize/Splitter/JsonSplitterTest.php new file mode 100644 index 0000000000000..7a84c62e44dbe --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Deserialize/Splitter/JsonSplitterTest.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Deserialize\Splitter; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Deserialize\Splitter\JsonSplitter; +use Symfony\Component\Serializer\Exception\InvalidResourceException; + +class JsonSplitterTest extends TestCase +{ + public function testSplitNull() + { + $this->assertNull((new JsonSplitter())->splitDict($this->createResource('null'), 0, -1)); + $this->assertNull((new JsonSplitter())->splitList($this->createResource('null'), 0, -1)); + } + + /** + * @dataProvider splitDictDataProvider + * + * @param list $expectedBoundaries + */ + public function testSplitDict(array $expectedBoundaries, string $content) + { + $this->assertSame($expectedBoundaries, iterator_to_array((new JsonSplitter())->splitDict(self::createResource($content), 0, -1))); + } + + /** + * @return iterable, 1: list}> + */ + public static function splitDictDataProvider(): iterable + { + yield [[], '{}']; + yield [['k' => [5, 2]], '{"k":10}']; + yield [['k' => [5, 4]], '{"k":[10]}']; + } + + /** + * @dataProvider splitListDataProvider + * + * @param list $expectedBoundaries + */ + public function testSplitList(array $expectedBoundaries, string $content) + { + $this->assertSame($expectedBoundaries, iterator_to_array((new JsonSplitter())->splitList(self::createResource($content), 0, -1))); + } + + /** + * @return iterable, 1: string}> + */ + public static function splitListDataProvider(): iterable + { + yield [[], '[]']; + yield [[[1, 3]], '[100]']; + yield [[[1, 3], [5, 3]], '[100,200]']; + yield [[[1, 1], [3, 5]], '[1,[2,3]]']; + yield [[[1, 1], [3, 5]], '[1,{2:3}]']; + } + + /** + * @dataProvider splitDictInvalidDataProvider + */ + public function testSplitDictInvalidThrowException(string $content) + { + $this->expectException(InvalidResourceException::class); + + iterator_to_array((new JsonSplitter())->splitDict(self::createResource($content), 0, -1)); + } + + /** + * @return iterable}> + */ + public static function splitDictInvalidDataProvider(): iterable + { + yield ['{100']; + yield ['{{}']; + yield ['{{}]']; + } + + /** + * @dataProvider splitListInvalidDataProvider + */ + public function testSplitListInvalidThrowException(string $content) + { + $this->expectException(InvalidResourceException::class); + + iterator_to_array((new JsonSplitter())->splitList(self::createResource($content), 0, -1)); + } + + /** + * @return iterable + */ + public static function splitListInvalidDataProvider(): iterable + { + yield ['[100']; + yield ['[[]']; + yield ['[[]}']; + } + + /** + * @return resource + */ + private static function createResource(string $content): mixed + { + /** @var resource $resource */ + $resource = fopen('php://memory', 'w+'); + + fwrite($resource, $content); + rewind($resource); + + return $resource; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Deserialize/Template/TemplateTest.php b/src/Symfony/Component/Serializer/Tests/Deserialize/Template/TemplateTest.php new file mode 100644 index 0000000000000..dc38b61183740 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Deserialize/Template/TemplateTest.php @@ -0,0 +1,166 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Deserialize\Template; + +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelBuilder; +use Symfony\Component\Serializer\Deserialize\DataModel\DataModelBuilderInterface; +use Symfony\Component\Serializer\Deserialize\Mapping\AttributePropertyMetadataLoader; +use Symfony\Component\Serializer\Deserialize\Mapping\PropertyMetadataLoader; +use Symfony\Component\Serializer\Deserialize\Mapping\TypePropertyMetadataLoader; +use Symfony\Component\Serializer\Deserialize\Template\EagerTemplateGenerator; +use Symfony\Component\Serializer\Deserialize\Template\LazyTemplateGenerator; +use Symfony\Component\Serializer\Deserialize\Template\Template; +use Symfony\Component\Serializer\Exception\UnsupportedFormatException; +use Symfony\Component\Serializer\Template\GroupTemplateVariation; +use Symfony\Component\Serializer\Template\TemplateVariation; +use Symfony\Component\Serializer\Template\TemplateVariationExtractorInterface; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Enum\DummyBackedEnum; +use Symfony\Component\Serializer\Type\PhpstanTypeExtractor; +use Symfony\Component\Serializer\Type\ReflectionTypeExtractor; +use Symfony\Component\Serializer\Type\Type; + +class TemplateTest extends TestCase +{ + private string $cacheDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->cacheDir = sprintf('%s/symfony_serializer_template', sys_get_temp_dir()); + + if (is_dir($this->cacheDir)) { + array_map('unlink', glob($this->cacheDir.'/*')); + rmdir($this->cacheDir); + } + } + + /** + * @dataProvider templatePathDataProvider + * + * @param list $variations + */ + public function testTemplatePath(string $expectedFilename, Type $type, array $variations, bool $lazy) + { + $templateVariationExtractor = $this->createStub(TemplateVariationExtractorInterface::class); + $templateVariationExtractor->method('extractVariationsFromConfig')->willReturn($variations); + + $template = new Template( + $templateVariationExtractor, + $this->createStub(DataModelBuilderInterface::class), + [], + $this->cacheDir, + false, + ); + + $config = (new DeserializeConfig())->withLazy($lazy); + + $this->assertSame(sprintf('%s/%s', $this->cacheDir, $expectedFilename), $template->path($type, 'format', $config)); + } + + /** + * @return iterable, 3: bool}> + */ + public static function templatePathDataProvider(): iterable + { + yield ['7617cc4b435dae7c97211c6082923b47.deserialize.eager.format.php', Type::int(), [], false]; + yield ['6e77b03690271cbee671df141e635536.deserialize.eager.format.php', Type::int(nullable: true), [], false]; + yield ['070660c7e72aa3e14a93c1039279afb6.deserialize.lazy.format.php', Type::mixed(), [], true]; + yield ['c13f5526678495e20da82e0a7c1c300b.deserialize.eager.format.php', Type::class(ClassicDummy::class), [], false]; + yield [ + 'c13f5526678495e20da82e0a7c1c300b.aa043a938b34c9e6dbe35f74e6b11dd2.deserialize.lazy.format.php', + Type::class(ClassicDummy::class), + [new GroupTemplateVariation('foo')], + true, + ]; + yield [ + 'c13f5526678495e20da82e0a7c1c300b.357ebc0d58122a5e2949ecd9dc04c02b.deserialize.lazy.format.php', + Type::class(ClassicDummy::class), + [new GroupTemplateVariation('foo'), new GroupTemplateVariation('bar')], + true, + ]; + } + + /** + * @dataProvider templateContentDataProvider + */ + public function testTemplateContent(string $fixture, Type $type) + { + $typeExtractor = new PhpstanTypeExtractor(new ReflectionTypeExtractor()); + $propertyMetadataLoader = new TypePropertyMetadataLoader( + new AttributePropertyMetadataLoader(new PropertyMetadataLoader($typeExtractor), $typeExtractor), + $typeExtractor, + ); + + $template = new Template( + $this->createStub(TemplateVariationExtractorInterface::class), + new DataModelBuilder($propertyMetadataLoader, $this->createStub(ContainerInterface::class)), + [ + 'foo' => [ + 'eager' => new EagerTemplateGenerator('DECODER'), + 'lazy' => new LazyTemplateGenerator('DECODER', 'SPLITTER'), + ], + ], + $this->cacheDir, + false, + ); + + $this->assertStringEqualsFile( + sprintf('%s/Fixtures/templates/deserialize/eager_%s.php', \dirname(__DIR__, 2), $fixture), + $template->content($type, 'foo', (new DeserializeConfig())->withLazy(false)), + ); + + $this->assertStringEqualsFile( + sprintf('%s/Fixtures/templates/deserialize/lazy_%s.php', \dirname(__DIR__, 2), $fixture), + $template->content($type, 'foo', (new DeserializeConfig())->withLazy(true)), + ); + } + + /** + * @return iterable + */ + public static function templateContentDataProvider(): iterable + { + yield ['scalar', Type::int()]; + yield ['nullable_scalar', Type::string(nullable: true)]; + yield ['mixed', Type::mixed()]; + yield ['backed_enum', Type::enum(DummyBackedEnum::class)]; + + yield ['list', Type::list()]; + yield ['nullable_list', Type::list(nullable: true)]; + yield ['iterable_list', Type::iterableList()]; + yield ['dict', Type::dict()]; + yield ['nullable_dict', Type::dict(nullable: true)]; + yield ['iterable_dict', Type::iterableDict()]; + + yield ['object', Type::class(ClassicDummy::class)]; + yield ['nullable_object', Type::class(ClassicDummy::class, nullable: true)]; + } + + public function testThrowOnUnsupportedFormat() + { + $this->expectException(UnsupportedFormatException::class); + + $template = new Template( + $this->createStub(TemplateVariationExtractorInterface::class), + $this->createStub(DataModelBuilderInterface::class), + [], + $this->cacheDir, + false, + ); + $template->content(Type::int(), 'format', new DeserializeConfig()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Config/CustomDeserializeConfig.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Config/CustomDeserializeConfig.php new file mode 100644 index 0000000000000..6bbb088aa16e8 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Config/CustomDeserializeConfig.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Config; + +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; + +final class CustomDeserializeConfig extends DeserializeConfig +{ + protected int $scale = 2; + + public function scale(): int + { + return $this->scale; + } + + public function withScale(int $scale): static + { + $clone = clone $this; + $clone->scale = $scale; + + return $clone; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Config/CustomSerializeConfig.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Config/CustomSerializeConfig.php new file mode 100644 index 0000000000000..b1c60bf09cab0 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Config/CustomSerializeConfig.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Config; + +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; + +final class CustomSerializeConfig extends SerializeConfig +{ + protected int $scale = 2; + + public function scale(): int + { + return $this->scale; + } + + public function withScale(int $scale): static + { + $clone = clone $this; + $clone->scale = $scale; + + return $clone; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Dto/AbstractDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Dto/AbstractDummy.php new file mode 100644 index 0000000000000..99e729da7c0e5 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Dto/AbstractDummy.php @@ -0,0 +1,7 @@ +scale() * $value); + } + + public static function divideAndCastToIntWithConfig(string $value, CustomDeserializeConfig $config): int + { + return (int) (((int) $value) / (2 * $config->scale())); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Dto/DummyWithGenerics.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Dto/DummyWithGenerics.php new file mode 100644 index 0000000000000..77b8fa3d24f4b --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Dto/DummyWithGenerics.php @@ -0,0 +1,14 @@ + + */ + public array $dummies = []; +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Dto/DummyWithGroups.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Dto/DummyWithGroups.php new file mode 100644 index 0000000000000..0c45812340ab8 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Dto/DummyWithGroups.php @@ -0,0 +1,19 @@ + */ + public $genericList; + + /** @var array */ + public $genericArrayList; + + /** @var array */ + public $genericDict; + + /** @var string[] */ + public $squareBracketList; + + /** @var array{foo: int, bar: string} */ + public $bracketList; + + /** @var array{} */ + public $emptyBracketList; + + /** @var \ArrayIterator */ + public $generic; + + /** @var Tv */ + public $genericParameter; + + public $undefined; + + /** + * @param mixed $_ + * + * @return mixed + */ + public function mixed($_) + { + return $this->mixed; + } + + /** + * @param bool $_ + * + * @return bool + */ + public function bool($_) + { + return $this->bool; + } + + /** + * @param boolean $_ + * + * @return boolean + */ + public function boolean($_) + { + return $this->boolean; + } + + /** + * @param true $_ + * + * @return true + */ + public function true($_) + { + return $this->true; + } + + /** + * @param false $_ + * + * @return false + */ + public function false($_) + { + return $this->false; + } + + /** + * @param int $_ + * + * @return int + */ + public function int($_) + { + return $this->int; + } + + /** + * @param int $_ + * + * @return int + */ + public function integer($_) + { + return $this->integer; + } + + /** + * @param float $_ + * + * @return float + */ + public function float($_) + { + return $this->float; + } + + /** + * @param string $_ + * + * @return string + */ + public function string($_) + { + return $this->string; + } + + /** + * @param resource $_ + * + * @return resource + */ + public function resource($_) + { + return $this->resource; + } + + /** + * @param object $_ + * + * @return object + */ + public function object($_) + { + return $this->object; + } + + /** + * @param callable $_ + * + * @return callable + */ + public function callable($_) + { + return $this->callable; + } + + /** + * @param array $_ + * + * @return array + */ + public function array($_) + { + return $this->array; + } + + /** + * @param list $_ + * + * @return list + */ + public function list($_) + { + return $this->list; + } + + /** + * @param iterable $_ + * + * @return iterable + */ + public function iterable($_) + { + return $this->iterable; + } + + /** + * @param non-empty-array $_ + * + * @return non-empty-array + */ + public function nonEmptyArray($_) + { + return $this->nonEmptyArray; + } + + /** + * @param non-empty-list $_ + * + * @return non-empty-list + */ + public function nonEmptyList($_) + { + return $this->nonEmptyList; + } + + /** + * @param null $_ + * + * @return null + */ + public function null($_) + { + return $this->null; + } + + /** + * @param self $_ + * + * @return self + */ + public function self($_) + { + return $this->self; + } + + /** + * @param static $_ + * + * @return static + */ + public function static($_) + { + return $this->static; + } + + /** + * @param parent $_ + * + * @return parent + */ + public function parent($_) + { + return $this->parent; + } + + /** + * @param scoped $_ + * + * @return scoped + */ + public function scoped($_) + { + return $this->scoped; + } + + /** + * @param DummyBackedEnum $_ + * + * @return DummyBackedEnum + */ + public function use($_) + { + return $this->use; + } + + /** + * @param ClassicDummy $_ + * + * @return ClassicDummy + */ + public function sameNamespace($_) + { + return $this->sameNamespace; + } + + /** + * @param int|string $_ + * + * @return int|string + */ + public function union($_) + { + return $this->union; + } + + /** + * @param ?int $_ + * + * @return ?int + */ + public function nullable($_) + { + return $this->nullable; + } + + /** + * @param int&string $_ + * + * @return int&string + */ + public function intersection($_) + { + return $this->intersection; + } + + /** + * @param list $_ + * + * @return list + */ + public function genericList($_) + { + return $this->genericList; + } + + /** + * @param array $_ + * + * @return array + */ + public function genericArrayList($_) + { + return $this->genericArrayList; + } + + /** + * @param array $_ + * + * @return array + */ + public function genericDict($_) + { + return $this->genericDict; + } + + /** + * @param string[] $_ + * + * @return string[] + */ + public function squareBracketList($_) + { + return $this->squareBracketList; + } + + /** + * @param array{foo: int, bar: string} $_ + * + * @return array{foo: int, bar: string} + */ + public function bracketList($_) + { + return $this->bracketList; + } + + /** + * @param array{} $_ + * + * @return array{} + */ + public function emptyBracketList($_) + { + return $this->emptyBracketList; + } + + /** + * @param \ArrayIterator $_ + * + * @return \ArrayIterator + */ + public function generic($_) + { + return $this->generic; + } + + /** + * @param Tv $_ + * + * @return Tv + */ + public function genericParameter($_) + { + return $this->genericParameter; + } + + public function void(): void + { + } + + public function never(): never + { + exit; + } + + public function undefined($_) + { + return $this->undefined; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Dto/ReflectionExtractableDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Dto/ReflectionExtractableDummy.php new file mode 100644 index 0000000000000..181c30d12b5c2 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Dto/ReflectionExtractableDummy.php @@ -0,0 +1,117 @@ +mixed; + } + + public function int(int $_): int + { + return $this->int; + } + + public function string(string $_): string + { + return $this->string; + } + + public function float(float $_): float + { + return $this->float; + } + + public function bool(bool $_): bool + { + return $this->bool; + } + + public function array(array $_): array + { + return $this->array; + } + + public function self(self $_): self + { + return $this->self; + } + + public function parent(parent $_): parent + { + return $this->parent; + } + + public function class(ClassicDummy $_): ClassicDummy + { + return $this->class; + } + + public function union(string|int $_): string|int + { + return $this->union; + } + + public function intersection(\Stringable&\Countable $_): \Stringable&\Countable + { + return $this->intersection; + } + + public function nullableBuiltin(?int $_): ?int + { + return $this->nullableBuiltin; + } + + public function nullableClass(?ClassicDummy $_): ?ClassicDummy + { + return $this->nullableClass; + } + + public function nullableUnion(string|int|null $_): string|int|null + { + return $this->nullableUnion; + } + + public function void(): void + { + } + + public function never(): never + { + exit; + } + + public function undefined($_) + { + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/DummyMessageInterface.php b/src/Symfony/Component/Serializer/Tests/Fixtures/DummyMessageInterface.php index 8f7589085b448..3ffb85829de1f 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/DummyMessageInterface.php +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/DummyMessageInterface.php @@ -19,6 +19,7 @@ #[DiscriminatorMap(typeProperty: 'type', mapping: [ 'one' => DummyMessageNumberOne::class, 'two' => DummyMessageNumberTwo::class, + 'three' => DummyMessageNumberThree::class, ])] interface DummyMessageInterface { diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/DummyMessageNumberThree.php b/src/Symfony/Component/Serializer/Tests/Fixtures/DummyMessageNumberThree.php new file mode 100644 index 0000000000000..e15fc62c7bae0 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/DummyMessageNumberThree.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures; + +/** + * @author Samuel Roze + */ +class DummyMessageNumberThree extends \stdClass implements DummyMessageInterface +{ +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Enum/DummyBackedEnum.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Enum/DummyBackedEnum.php new file mode 100644 index 0000000000000..071eaf4038cc7 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Enum/DummyBackedEnum.php @@ -0,0 +1,9 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures; + +interface FooDummyInterface +{ +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/FooImplementationDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/FooImplementationDummy.php new file mode 100644 index 0000000000000..b7f7194a5a19a --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/FooImplementationDummy.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures; + +final class FooImplementationDummy implements FooDummyInterface +{ + /** + * @var string + */ + public $name; +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/FooInterfaceDummyDenormalizer.php b/src/Symfony/Component/Serializer/Tests/Fixtures/FooInterfaceDummyDenormalizer.php new file mode 100644 index 0000000000000..a8c45373b70ee --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/FooInterfaceDummyDenormalizer.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures; + +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +final class FooInterfaceDummyDenormalizer implements DenormalizerInterface +{ + public function denormalize(mixed $data, string $type, string $format = null, array $context = []): array + { + $result = []; + foreach ($data as $foo) { + $fooDummy = new FooImplementationDummy(); + $fooDummy->name = $foo['name']; + $result[] = $fooDummy; + } + + return $result; + } + + public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool + { + if (str_ends_with($type, '[]')) { + $className = substr($type, 0, -2); + $classImplements = class_implements($className); + \assert(\is_array($classImplements)); + + return class_exists($className) && \in_array(FooDummyInterface::class, $classImplements, true); + } + + return false; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [FooDummyInterface::class.'[]' => false]; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/ObjectCollectionPropertyDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/ObjectCollectionPropertyDummy.php new file mode 100644 index 0000000000000..cbb77987bd8ab --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/ObjectCollectionPropertyDummy.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures; + +final class ObjectCollectionPropertyDummy +{ + /** + * @var FooImplementationDummy[] + */ + public $foo; + + public function getFoo(): array + { + return $this->foo; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_backed_enum.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_backed_enum.php new file mode 100644 index 0000000000000..3f62ecffa2d21 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_backed_enum.php @@ -0,0 +1,16 @@ + + */ +return static function (mixed $resource, \Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig $config, \Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface $instantiator, \Psr\Container\ContainerInterface $services): mixed { + $providers["array"] = static function (?iterable $data) use ($config, $instantiator, &$providers): array { + $iterable = static function (iterable $data) use ($config, $instantiator, &$providers): iterable { + foreach ($data as $k => $v) { + yield $k => ($providers["mixed"])($v); + } + }; + return \iterator_to_array(($iterable)($data)); + }; + $providers["mixed"] = static function (mixed $data) use ($config, $instantiator, &$providers): mixed { + return $data; + }; + return ($providers["array"])("\\DECODER"::decode($resource, 0, -1, $config)); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_iterable_dict.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_iterable_dict.php new file mode 100644 index 0000000000000..ec079767f243a --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_iterable_dict.php @@ -0,0 +1,20 @@ + + */ +return static function (mixed $resource, \Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig $config, \Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface $instantiator, \Psr\Container\ContainerInterface $services): mixed { + $providers["iterable"] = static function (?iterable $data) use ($config, $instantiator, &$providers): iterable { + $iterable = static function (iterable $data) use ($config, $instantiator, &$providers): iterable { + foreach ($data as $k => $v) { + yield $k => ($providers["mixed"])($v); + } + }; + return ($iterable)($data); + }; + $providers["mixed"] = static function (mixed $data) use ($config, $instantiator, &$providers): mixed { + return $data; + }; + return ($providers["iterable"])("\\DECODER"::decode($resource, 0, -1, $config)); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_iterable_list.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_iterable_list.php new file mode 100644 index 0000000000000..7043d4cea4525 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_iterable_list.php @@ -0,0 +1,20 @@ + + */ +return static function (mixed $resource, \Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig $config, \Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface $instantiator, \Psr\Container\ContainerInterface $services): mixed { + $providers["iterable"] = static function (?iterable $data) use ($config, $instantiator, &$providers): iterable { + $iterable = static function (iterable $data) use ($config, $instantiator, &$providers): iterable { + foreach ($data as $k => $v) { + yield $k => ($providers["mixed"])($v); + } + }; + return ($iterable)($data); + }; + $providers["mixed"] = static function (mixed $data) use ($config, $instantiator, &$providers): mixed { + return $data; + }; + return ($providers["iterable"])("\\DECODER"::decode($resource, 0, -1, $config)); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_list.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_list.php new file mode 100644 index 0000000000000..a64db8c2580f8 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_list.php @@ -0,0 +1,20 @@ + + */ +return static function (mixed $resource, \Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig $config, \Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface $instantiator, \Psr\Container\ContainerInterface $services): mixed { + $providers["array"] = static function (?iterable $data) use ($config, $instantiator, &$providers): array { + $iterable = static function (iterable $data) use ($config, $instantiator, &$providers): iterable { + foreach ($data as $k => $v) { + yield $k => ($providers["mixed"])($v); + } + }; + return \iterator_to_array(($iterable)($data)); + }; + $providers["mixed"] = static function (mixed $data) use ($config, $instantiator, &$providers): mixed { + return $data; + }; + return ($providers["array"])("\\DECODER"::decode($resource, 0, -1, $config)); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_mixed.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_mixed.php new file mode 100644 index 0000000000000..7cac3299abb58 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_mixed.php @@ -0,0 +1,12 @@ + + */ +return static function (mixed $resource, \Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig $config, \Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface $instantiator, \Psr\Container\ContainerInterface $services): mixed { + $providers["?array"] = static function (?iterable $data) use ($config, $instantiator, &$providers): ?array { + if (null === $data) { + return null; + } + $iterable = static function (iterable $data) use ($config, $instantiator, &$providers): iterable { + foreach ($data as $k => $v) { + yield $k => ($providers["mixed"])($v); + } + }; + return \iterator_to_array(($iterable)($data)); + }; + $providers["mixed"] = static function (mixed $data) use ($config, $instantiator, &$providers): mixed { + return $data; + }; + return ($providers["?array"])("\\DECODER"::decode($resource, 0, -1, $config)); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_nullable_list.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_nullable_list.php new file mode 100644 index 0000000000000..4cb2ab51ffd07 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_nullable_list.php @@ -0,0 +1,23 @@ + + */ +return static function (mixed $resource, \Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig $config, \Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface $instantiator, \Psr\Container\ContainerInterface $services): mixed { + $providers["?array"] = static function (?iterable $data) use ($config, $instantiator, &$providers): ?array { + if (null === $data) { + return null; + } + $iterable = static function (iterable $data) use ($config, $instantiator, &$providers): iterable { + foreach ($data as $k => $v) { + yield $k => ($providers["mixed"])($v); + } + }; + return \iterator_to_array(($iterable)($data)); + }; + $providers["mixed"] = static function (mixed $data) use ($config, $instantiator, &$providers): mixed { + return $data; + }; + return ($providers["?array"])("\\DECODER"::decode($resource, 0, -1, $config)); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_nullable_object.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_nullable_object.php new file mode 100644 index 0000000000000..b061bea7511a2 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_nullable_object.php @@ -0,0 +1,40 @@ +instantiate("Symfony\\Component\\Serializer\\Tests\\Fixtures\\Dto\\ClassicDummy", $properties); + }; + $providers["int"] = static function (mixed $data) use ($config, $instantiator, &$providers): mixed { + try { + return (int) ($data); + } catch (\Throwable $e) { + throw new \Symfony\Component\Serializer\Exception\UnexpectedValueException(sprintf("Cannot cast \"%s\" to \"int\"", get_debug_type($data))); + } + }; + $providers["string"] = static function (mixed $data) use ($config, $instantiator, &$providers): mixed { + try { + return (string) ($data); + } catch (\Throwable $e) { + throw new \Symfony\Component\Serializer\Exception\UnexpectedValueException(sprintf("Cannot cast \"%s\" to \"string\"", get_debug_type($data))); + } + }; + return ($providers["?Symfony\\Component\\Serializer\\Tests\\Fixtures\\Dto\\ClassicDummy"])("\\DECODER"::decode($resource, 0, -1, $config)); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_nullable_scalar.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_nullable_scalar.php new file mode 100644 index 0000000000000..cd8852d3da8d0 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_nullable_scalar.php @@ -0,0 +1,19 @@ +instantiate("Symfony\\Component\\Serializer\\Tests\\Fixtures\\Dto\\ClassicDummy", $properties); + }; + $providers["int"] = static function (mixed $data) use ($config, $instantiator, &$providers): mixed { + try { + return (int) ($data); + } catch (\Throwable $e) { + throw new \Symfony\Component\Serializer\Exception\UnexpectedValueException(sprintf("Cannot cast \"%s\" to \"int\"", get_debug_type($data))); + } + }; + $providers["string"] = static function (mixed $data) use ($config, $instantiator, &$providers): mixed { + try { + return (string) ($data); + } catch (\Throwable $e) { + throw new \Symfony\Component\Serializer\Exception\UnexpectedValueException(sprintf("Cannot cast \"%s\" to \"string\"", get_debug_type($data))); + } + }; + return ($providers["Symfony\\Component\\Serializer\\Tests\\Fixtures\\Dto\\ClassicDummy"])("\\DECODER"::decode($resource, 0, -1, $config)); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_scalar.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_scalar.php new file mode 100644 index 0000000000000..34a055ce14238 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/eager_scalar.php @@ -0,0 +1,16 @@ + + */ +return static function (mixed $resource, \Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig $config, \Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface $instantiator, \Psr\Container\ContainerInterface $services): mixed { + $providers["array"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): array { + $boundaries = "\\SPLITTER"::splitDict($resource, $offset, $length); + $iterable = static function (mixed $resource, iterable $boundaries) use ($config, $instantiator, &$providers): iterable { + foreach ($boundaries as $k => $b) { + yield $k => ($providers["mixed"])($resource, $b[0], $b[1]); + } + }; + return \iterator_to_array(($iterable)($resource, $boundaries)); + }; + $providers["mixed"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): mixed { + $data = "\\DECODER"::decode($resource, $offset, $length, $config); + return $data; + }; + return ($providers["array"])($resource, 0, -1); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_iterable_dict.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_iterable_dict.php new file mode 100644 index 0000000000000..03c30767ed454 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_iterable_dict.php @@ -0,0 +1,22 @@ + + */ +return static function (mixed $resource, \Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig $config, \Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface $instantiator, \Psr\Container\ContainerInterface $services): mixed { + $providers["iterable"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): iterable { + $boundaries = "\\SPLITTER"::splitDict($resource, $offset, $length); + $iterable = static function (mixed $resource, iterable $boundaries) use ($config, $instantiator, &$providers): iterable { + foreach ($boundaries as $k => $b) { + yield $k => ($providers["mixed"])($resource, $b[0], $b[1]); + } + }; + return ($iterable)($resource, $boundaries); + }; + $providers["mixed"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): mixed { + $data = "\\DECODER"::decode($resource, $offset, $length, $config); + return $data; + }; + return ($providers["iterable"])($resource, 0, -1); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_iterable_list.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_iterable_list.php new file mode 100644 index 0000000000000..a025eff0598fa --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_iterable_list.php @@ -0,0 +1,22 @@ + + */ +return static function (mixed $resource, \Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig $config, \Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface $instantiator, \Psr\Container\ContainerInterface $services): mixed { + $providers["iterable"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): iterable { + $boundaries = "\\SPLITTER"::splitList($resource, $offset, $length); + $iterable = static function (mixed $resource, iterable $boundaries) use ($config, $instantiator, &$providers): iterable { + foreach ($boundaries as $k => $b) { + yield $k => ($providers["mixed"])($resource, $b[0], $b[1]); + } + }; + return ($iterable)($resource, $boundaries); + }; + $providers["mixed"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): mixed { + $data = "\\DECODER"::decode($resource, $offset, $length, $config); + return $data; + }; + return ($providers["iterable"])($resource, 0, -1); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_list.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_list.php new file mode 100644 index 0000000000000..a3254d1c6e5a2 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_list.php @@ -0,0 +1,22 @@ + + */ +return static function (mixed $resource, \Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig $config, \Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface $instantiator, \Psr\Container\ContainerInterface $services): mixed { + $providers["array"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): array { + $boundaries = "\\SPLITTER"::splitList($resource, $offset, $length); + $iterable = static function (mixed $resource, iterable $boundaries) use ($config, $instantiator, &$providers): iterable { + foreach ($boundaries as $k => $b) { + yield $k => ($providers["mixed"])($resource, $b[0], $b[1]); + } + }; + return \iterator_to_array(($iterable)($resource, $boundaries)); + }; + $providers["mixed"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): mixed { + $data = "\\DECODER"::decode($resource, $offset, $length, $config); + return $data; + }; + return ($providers["array"])($resource, 0, -1); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_mixed.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_mixed.php new file mode 100644 index 0000000000000..f04f09cad7e50 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_mixed.php @@ -0,0 +1,13 @@ + + */ +return static function (mixed $resource, \Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig $config, \Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface $instantiator, \Psr\Container\ContainerInterface $services): mixed { + $providers["?array"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): ?array { + $boundaries = "\\SPLITTER"::splitDict($resource, $offset, $length); + if (null === $boundaries) { + return null; + } + $iterable = static function (mixed $resource, iterable $boundaries) use ($config, $instantiator, &$providers): iterable { + foreach ($boundaries as $k => $b) { + yield $k => ($providers["mixed"])($resource, $b[0], $b[1]); + } + }; + return \iterator_to_array(($iterable)($resource, $boundaries)); + }; + $providers["mixed"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): mixed { + $data = "\\DECODER"::decode($resource, $offset, $length, $config); + return $data; + }; + return ($providers["?array"])($resource, 0, -1); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_nullable_list.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_nullable_list.php new file mode 100644 index 0000000000000..12672c9a3382a --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_nullable_list.php @@ -0,0 +1,25 @@ + + */ +return static function (mixed $resource, \Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig $config, \Symfony\Component\Serializer\Deserialize\Instantiator\InstantiatorInterface $instantiator, \Psr\Container\ContainerInterface $services): mixed { + $providers["?array"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): ?array { + $boundaries = "\\SPLITTER"::splitList($resource, $offset, $length); + if (null === $boundaries) { + return null; + } + $iterable = static function (mixed $resource, iterable $boundaries) use ($config, $instantiator, &$providers): iterable { + foreach ($boundaries as $k => $b) { + yield $k => ($providers["mixed"])($resource, $b[0], $b[1]); + } + }; + return \iterator_to_array(($iterable)($resource, $boundaries)); + }; + $providers["mixed"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): mixed { + $data = "\\DECODER"::decode($resource, $offset, $length, $config); + return $data; + }; + return ($providers["?array"])($resource, 0, -1); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_nullable_object.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_nullable_object.php new file mode 100644 index 0000000000000..9688ac921f553 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_nullable_object.php @@ -0,0 +1,47 @@ + $b) { + if ("id" === $k) { + $properties["id"] = static function () use ($resource, $b, $config, $instantiator, &$providers): mixed { + return ($providers["int"])($resource, $b[0], $b[1]); + }; + continue; + } + if ("name" === $k) { + $properties["name"] = static function () use ($resource, $b, $config, $instantiator, &$providers): mixed { + return ($providers["string"])($resource, $b[0], $b[1]); + }; + continue; + } + } + return $instantiator->instantiate("Symfony\\Component\\Serializer\\Tests\\Fixtures\\Dto\\ClassicDummy", $properties); + }; + $providers["int"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): mixed { + $data = "\\DECODER"::decode($resource, $offset, $length, $config); + try { + return (int) ($data); + } catch (\Throwable $e) { + throw new \Symfony\Component\Serializer\Exception\UnexpectedValueException(sprintf("Cannot cast \"%s\" to \"int\"", get_debug_type($data))); + } + }; + $providers["string"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): mixed { + $data = "\\DECODER"::decode($resource, $offset, $length, $config); + try { + return (string) ($data); + } catch (\Throwable $e) { + throw new \Symfony\Component\Serializer\Exception\UnexpectedValueException(sprintf("Cannot cast \"%s\" to \"string\"", get_debug_type($data))); + } + }; + return ($providers["?Symfony\\Component\\Serializer\\Tests\\Fixtures\\Dto\\ClassicDummy"])($resource, 0, -1); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_nullable_scalar.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_nullable_scalar.php new file mode 100644 index 0000000000000..bea2c4d6f3f72 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_nullable_scalar.php @@ -0,0 +1,20 @@ + $b) { + if ("id" === $k) { + $properties["id"] = static function () use ($resource, $b, $config, $instantiator, &$providers): mixed { + return ($providers["int"])($resource, $b[0], $b[1]); + }; + continue; + } + if ("name" === $k) { + $properties["name"] = static function () use ($resource, $b, $config, $instantiator, &$providers): mixed { + return ($providers["string"])($resource, $b[0], $b[1]); + }; + continue; + } + } + return $instantiator->instantiate("Symfony\\Component\\Serializer\\Tests\\Fixtures\\Dto\\ClassicDummy", $properties); + }; + $providers["int"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): mixed { + $data = "\\DECODER"::decode($resource, $offset, $length, $config); + try { + return (int) ($data); + } catch (\Throwable $e) { + throw new \Symfony\Component\Serializer\Exception\UnexpectedValueException(sprintf("Cannot cast \"%s\" to \"int\"", get_debug_type($data))); + } + }; + $providers["string"] = static function (mixed $resource, int $offset, int $length) use ($config, $instantiator, &$providers): mixed { + $data = "\\DECODER"::decode($resource, $offset, $length, $config); + try { + return (string) ($data); + } catch (\Throwable $e) { + throw new \Symfony\Component\Serializer\Exception\UnexpectedValueException(sprintf("Cannot cast \"%s\" to \"string\"", get_debug_type($data))); + } + }; + return ($providers["Symfony\\Component\\Serializer\\Tests\\Fixtures\\Dto\\ClassicDummy"])($resource, 0, -1); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_scalar.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_scalar.php new file mode 100644 index 0000000000000..4ab11be7fac0f --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/deserialize/lazy_scalar.php @@ -0,0 +1,17 @@ +value, $config->json()->flags())); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_dict.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_dict.php new file mode 100644 index 0000000000000..740542fcf0d60 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_dict.php @@ -0,0 +1,17 @@ + $data + * @param resource $resource + */ +return static function (mixed $data, mixed $resource, \Symfony\Component\Serializer\Serialize\Config\SerializeConfig $config, \Psr\Container\ContainerInterface $services): void { + \fwrite($resource, "{"); + $prefix_0 = ""; + foreach ($data as $key_0 => $value_0) { + $key_0 = \substr(\json_encode($key_0, $config->json()->flags()), 1, -1); + \fwrite($resource, "{$prefix_0}\"{$key_0}\":"); + \fwrite($resource, \json_encode($value_0, $config->json()->flags())); + $prefix_0 = ","; + } + \fwrite($resource, "}"); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_iterable_dict.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_iterable_dict.php new file mode 100644 index 0000000000000..9d5a7654e1b24 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_iterable_dict.php @@ -0,0 +1,17 @@ + $data + * @param resource $resource + */ +return static function (mixed $data, mixed $resource, \Symfony\Component\Serializer\Serialize\Config\SerializeConfig $config, \Psr\Container\ContainerInterface $services): void { + \fwrite($resource, "{"); + $prefix_0 = ""; + foreach ($data as $key_0 => $value_0) { + $key_0 = \substr(\json_encode($key_0, $config->json()->flags()), 1, -1); + \fwrite($resource, "{$prefix_0}\"{$key_0}\":"); + \fwrite($resource, \json_encode($value_0, $config->json()->flags())); + $prefix_0 = ","; + } + \fwrite($resource, "}"); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_iterable_list.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_iterable_list.php new file mode 100644 index 0000000000000..3800170396041 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_iterable_list.php @@ -0,0 +1,16 @@ + $data + * @param resource $resource + */ +return static function (mixed $data, mixed $resource, \Symfony\Component\Serializer\Serialize\Config\SerializeConfig $config, \Psr\Container\ContainerInterface $services): void { + \fwrite($resource, "["); + $prefix_0 = ""; + foreach ($data as $value_0) { + \fwrite($resource, $prefix_0); + \fwrite($resource, \json_encode($value_0, $config->json()->flags())); + $prefix_0 = ","; + } + \fwrite($resource, "]"); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_list.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_list.php new file mode 100644 index 0000000000000..25d5edf14b681 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_list.php @@ -0,0 +1,16 @@ + $data + * @param resource $resource + */ +return static function (mixed $data, mixed $resource, \Symfony\Component\Serializer\Serialize\Config\SerializeConfig $config, \Psr\Container\ContainerInterface $services): void { + \fwrite($resource, "["); + $prefix_0 = ""; + foreach ($data as $value_0) { + \fwrite($resource, $prefix_0); + \fwrite($resource, \json_encode($value_0, $config->json()->flags())); + $prefix_0 = ","; + } + \fwrite($resource, "]"); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_mixed.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_mixed.php new file mode 100644 index 0000000000000..cf8c845adfb5f --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_mixed.php @@ -0,0 +1,9 @@ +json()->flags())); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_nullable_dict.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_nullable_dict.php new file mode 100644 index 0000000000000..af7367a24c0a3 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_nullable_dict.php @@ -0,0 +1,21 @@ + $data + * @param resource $resource + */ +return static function (mixed $data, mixed $resource, \Symfony\Component\Serializer\Serialize\Config\SerializeConfig $config, \Psr\Container\ContainerInterface $services): void { + if (null === $data) { + \fwrite($resource, "null"); + } else { + \fwrite($resource, "{"); + $prefix_0 = ""; + foreach ($data as $key_0 => $value_0) { + $key_0 = \substr(\json_encode($key_0, $config->json()->flags()), 1, -1); + \fwrite($resource, "{$prefix_0}\"{$key_0}\":"); + \fwrite($resource, \json_encode($value_0, $config->json()->flags())); + $prefix_0 = ","; + } + \fwrite($resource, "}"); + } +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_nullable_list.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_nullable_list.php new file mode 100644 index 0000000000000..757fb6eaaee8e --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_nullable_list.php @@ -0,0 +1,20 @@ + $data + * @param resource $resource + */ +return static function (mixed $data, mixed $resource, \Symfony\Component\Serializer\Serialize\Config\SerializeConfig $config, \Psr\Container\ContainerInterface $services): void { + if (null === $data) { + \fwrite($resource, "null"); + } else { + \fwrite($resource, "["); + $prefix_0 = ""; + foreach ($data as $value_0) { + \fwrite($resource, $prefix_0); + \fwrite($resource, \json_encode($value_0, $config->json()->flags())); + $prefix_0 = ","; + } + \fwrite($resource, "]"); + } +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_nullable_object.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_nullable_object.php new file mode 100644 index 0000000000000..78a2b5821c9e6 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_nullable_object.php @@ -0,0 +1,17 @@ +id, $config->json()->flags())); + \fwrite($resource, ",\"name\":"); + \fwrite($resource, \json_encode($data->name, $config->json()->flags())); + \fwrite($resource, "}"); + } +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_nullable_scalar.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_nullable_scalar.php new file mode 100644 index 0000000000000..9f992c49adb2b --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_nullable_scalar.php @@ -0,0 +1,13 @@ +json()->flags())); + } +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_object.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_object.php new file mode 100644 index 0000000000000..88832bfdfb0e4 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_object.php @@ -0,0 +1,13 @@ +id, $config->json()->flags())); + \fwrite($resource, ",\"name\":"); + \fwrite($resource, \json_encode($data->name, $config->json()->flags())); + \fwrite($resource, "}"); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_scalar.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_scalar.php new file mode 100644 index 0000000000000..934e3cf87f110 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/json_scalar.php @@ -0,0 +1,9 @@ +json()->flags())); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_backed_enum.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_backed_enum.php new file mode 100644 index 0000000000000..ee7f210f451c4 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_backed_enum.php @@ -0,0 +1,10 @@ +value; + "\\ENCODER"::encode($resource, $normalized, $config); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_dict.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_dict.php new file mode 100644 index 0000000000000..059782112ff94 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_dict.php @@ -0,0 +1,13 @@ + $data + * @param resource $resource + */ +return static function (mixed $data, mixed $resource, \Symfony\Component\Serializer\Serialize\Config\SerializeConfig $config, \Psr\Container\ContainerInterface $services): void { + $normalized = []; + foreach ($data as $key_0 => $value_0) { + $normalized[$key_0] = $value_0; + } + "\\ENCODER"::encode($resource, $normalized, $config); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_iterable_dict.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_iterable_dict.php new file mode 100644 index 0000000000000..0aa241e8d6e87 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_iterable_dict.php @@ -0,0 +1,13 @@ + $data + * @param resource $resource + */ +return static function (mixed $data, mixed $resource, \Symfony\Component\Serializer\Serialize\Config\SerializeConfig $config, \Psr\Container\ContainerInterface $services): void { + $normalized = []; + foreach ($data as $key_0 => $value_0) { + $normalized[$key_0] = $value_0; + } + "\\ENCODER"::encode($resource, $normalized, $config); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_iterable_list.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_iterable_list.php new file mode 100644 index 0000000000000..381aaa3dcae4a --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_iterable_list.php @@ -0,0 +1,13 @@ + $data + * @param resource $resource + */ +return static function (mixed $data, mixed $resource, \Symfony\Component\Serializer\Serialize\Config\SerializeConfig $config, \Psr\Container\ContainerInterface $services): void { + $normalized = []; + foreach ($data as $key_0 => $value_0) { + $normalized[$key_0] = $value_0; + } + "\\ENCODER"::encode($resource, $normalized, $config); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_list.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_list.php new file mode 100644 index 0000000000000..13bda6bcba474 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_list.php @@ -0,0 +1,13 @@ + $data + * @param resource $resource + */ +return static function (mixed $data, mixed $resource, \Symfony\Component\Serializer\Serialize\Config\SerializeConfig $config, \Psr\Container\ContainerInterface $services): void { + $normalized = []; + foreach ($data as $key_0 => $value_0) { + $normalized[$key_0] = $value_0; + } + "\\ENCODER"::encode($resource, $normalized, $config); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_mixed.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_mixed.php new file mode 100644 index 0000000000000..52110b5ae6d8b --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_mixed.php @@ -0,0 +1,10 @@ + $data + * @param resource $resource + */ +return static function (mixed $data, mixed $resource, \Symfony\Component\Serializer\Serialize\Config\SerializeConfig $config, \Psr\Container\ContainerInterface $services): void { + if (null === $data) { + "\\ENCODER"::encode($resource, null, $config); + return; + } + $normalized = []; + foreach ($data as $key_0 => $value_0) { + $normalized[$key_0] = $value_0; + } + "\\ENCODER"::encode($resource, $normalized, $config); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_nullable_list.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_nullable_list.php new file mode 100644 index 0000000000000..2dafe7c1524af --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_nullable_list.php @@ -0,0 +1,17 @@ + $data + * @param resource $resource + */ +return static function (mixed $data, mixed $resource, \Symfony\Component\Serializer\Serialize\Config\SerializeConfig $config, \Psr\Container\ContainerInterface $services): void { + if (null === $data) { + "\\ENCODER"::encode($resource, null, $config); + return; + } + $normalized = []; + foreach ($data as $key_0 => $value_0) { + $normalized[$key_0] = $value_0; + } + "\\ENCODER"::encode($resource, $normalized, $config); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_nullable_object.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_nullable_object.php new file mode 100644 index 0000000000000..d8d1d9f2fc349 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_nullable_object.php @@ -0,0 +1,15 @@ +id; + $normalized["name"] = $data->name; + "\\ENCODER"::encode($resource, $normalized, $config); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_nullable_scalar.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_nullable_scalar.php new file mode 100644 index 0000000000000..0e469a4d6bc9f --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_nullable_scalar.php @@ -0,0 +1,14 @@ +id; + $normalized["name"] = $data->name; + "\\ENCODER"::encode($resource, $normalized, $config); +}; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_scalar.php b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_scalar.php new file mode 100644 index 0000000000000..3f609194896ae --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/templates/serialize/normalizer_scalar.php @@ -0,0 +1,10 @@ + 'b']], ['buz', 'buz', ['groups' => ['c']]], ['buz', 'buz', []], + ['buzForExport', 'buz', ['groups' => ['*']]], ]; } diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php index aa9d57ad13647..cadafa77f8697 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -32,9 +32,11 @@ use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; +use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; @@ -139,6 +141,29 @@ public function testDenormalizeWithNestedAttributesWithoutMetadata() $this->assertNull($test->notfoo); } + public function testDenormalizeWithSnakeCaseNestedAttributes() + { + $factory = new ClassMetadataFactory(new AnnotationLoader()); + $normalizer = new ObjectNormalizer($factory, new CamelCaseToSnakeCaseNameConverter()); + $data = [ + 'one' => [ + 'two_three' => 'fooBar', + ], + ]; + $test = $normalizer->denormalize($data, SnakeCaseNestedDummy::class, 'any'); + $this->assertSame('fooBar', $test->fooBar); + } + + public function testNormalizeWithSnakeCaseNestedAttributes() + { + $factory = new ClassMetadataFactory(new AnnotationLoader()); + $normalizer = new ObjectNormalizer($factory, new CamelCaseToSnakeCaseNameConverter()); + $dummy = new SnakeCaseNestedDummy(); + $dummy->fooBar = 'fooBar'; + $test = $normalizer->normalize($dummy, 'any'); + $this->assertSame(['one' => ['two_three' => 'fooBar']], $test); + } + public function testDenormalizeWithNestedAttributes() { $normalizer = new AbstractObjectNormalizerWithMetadata(); @@ -744,6 +769,23 @@ public function supportsNormalization(mixed $data, string $format = null, array $this->assertSame('called', $object->bar); } + + public function testDenormalizeUnionOfEnums() + { + $serializer = new Serializer([ + new BackedEnumNormalizer(), + new ObjectNormalizer( + classMetadataFactory: new ClassMetadataFactory(new AnnotationLoader()), + propertyTypeExtractor: new PropertyInfoExtractor([], [new ReflectionExtractor()]), + ), + ]); + + $normalized = $serializer->normalize(new DummyWithEnumUnion(EnumA::A)); + $this->assertEquals(new DummyWithEnumUnion(EnumA::A), $serializer->denormalize($normalized, DummyWithEnumUnion::class)); + + $normalized = $serializer->normalize(new DummyWithEnumUnion(EnumB::B)); + $this->assertEquals(new DummyWithEnumUnion(EnumB::B), $serializer->denormalize($normalized, DummyWithEnumUnion::class)); + } } class AbstractObjectNormalizerDummy extends AbstractObjectNormalizer @@ -840,6 +882,12 @@ public function __construct( } } +class SnakeCaseNestedDummy +{ + #[SerializedPath('[one][two_three]')] + public $fooBar; +} + #[DiscriminatorMap(typeProperty: 'type', mapping: [ 'first' => FirstNestedDummyWithConstructorAndDiscriminator::class, 'second' => SecondNestedDummyWithConstructorAndDiscriminator::class, @@ -1119,3 +1167,21 @@ public function __sleep(): array throw new \Error('not serializable'); } } + +enum EnumA: string +{ + case A = 'a'; +} + +enum EnumB: string +{ + case B = 'b'; +} + +class DummyWithEnumUnion +{ + public function __construct( + public readonly EnumA|EnumB $enum, + ) { + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php index 8886e667ee93a..7375b530dfb27 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php @@ -82,6 +82,7 @@ public function contextMetadataDummyProvider(): array return [ [ContextMetadataDummy::class], [ContextChildMetadataDummy::class], + [ClassAndPropertyContextMetadataDummy::class], ]; } @@ -100,7 +101,7 @@ public function testContextDenormalizeWithNameConverter() class ContextMetadataDummy { /** - * @var \DateTime + * @var \DateTimeImmutable */ #[Groups(['extended', 'simple'])] #[Context([DateTimeNormalizer::FORMAT_KEY => \DateTimeInterface::RFC3339])] @@ -118,7 +119,7 @@ class ContextMetadataDummy class ContextChildMetadataDummy { /** - * @var \DateTime + * @var \DateTimeImmutable */ #[Groups(['extended', 'simple'])] #[DummyContextChild([DateTimeNormalizer::FORMAT_KEY => \DateTimeInterface::RFC3339])] @@ -133,10 +134,28 @@ class ContextChildMetadataDummy public $date; } +#[Context(context: [DateTimeNormalizer::FORMAT_KEY => \DateTimeInterface::RFC3339])] +#[Context( + context: [DateTimeNormalizer::FORMAT_KEY => \DateTimeInterface::RFC3339_EXTENDED], + groups: ['extended'], +)] +class ClassAndPropertyContextMetadataDummy +{ + /** + * @var \DateTimeImmutable + */ + #[Groups(['extended', 'simple'])] + #[Context( + denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'd/m/Y'], + groups: ['simple'], + )] + public $date; +} + class ContextMetadataNamingDummy { /** - * @var \DateTime + * @var \DateTimeImmutable */ #[Context([DateTimeNormalizer::FORMAT_KEY => 'd/m/Y'])] public $createdAt; diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/DummyContextChild.php b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/DummyContextChild.php index f66ef702a565d..25f85d61fec1d 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/DummyContextChild.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/DummyContextChild.php @@ -13,7 +13,7 @@ use Symfony\Component\Serializer\Annotation\Context; -#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)] +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)] class DummyContextChild extends Context { } diff --git a/src/Symfony/Component/Serializer/Tests/Php/ArgumentsNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/ArgumentsNodeTest.php new file mode 100644 index 0000000000000..26557bc411b88 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/ArgumentsNodeTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\ArgumentsNode; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ScalarNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class ArgumentsNodeTest extends TestCase +{ + public function testCompile() + { + (new ArgumentsNode([new VariableNode('foo')]))->compile($compiler = new Compiler()); + $this->assertSame('$foo', $compiler->source()); + + (new ArgumentsNode([new ScalarNode(123), new VariableNode('bar', byReference: true)]))->compile($compiler = new Compiler()); + $this->assertSame('123, &$bar', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/ArrayAccessNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/ArrayAccessNodeTest.php new file mode 100644 index 0000000000000..58d93d6e69989 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/ArrayAccessNodeTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\ArrayAccessNode; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ScalarNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class ArrayAccessNodeTest extends TestCase +{ + public function testCompile() + { + (new ArrayAccessNode(new VariableNode('foo'), new ScalarNode('bar')))->compile($compiler = new Compiler()); + $this->assertSame('$foo["bar"]', $compiler->source()); + + (new ArrayAccessNode(new VariableNode('foo'), key: null))->compile($compiler = new Compiler()); + $this->assertSame('$foo[]', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/ArrayNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/ArrayNodeTest.php new file mode 100644 index 0000000000000..e217851f7a36d --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/ArrayNodeTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\ArrayNode; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ScalarNode; + +class ArrayNodeTest extends TestCase +{ + public function testCompile() + { + (new ArrayNode([]))->compile($compiler = new Compiler()); + $this->assertSame('[]', $compiler->source()); + + (new ArrayNode([new ScalarNode('foo'), new ScalarNode('bar')]))->compile($compiler = new Compiler()); + $this->assertSame('["foo", "bar"]', $compiler->source()); + + (new ArrayNode(['foo' => new ScalarNode('foo'), 'bar' => new ScalarNode('bar')]))->compile($compiler = new Compiler()); + $this->assertSame('["foo" => "foo", "bar" => "bar"]', $compiler->source()); + + (new ArrayNode([1 => new ScalarNode('foo'), 3 => new ScalarNode('bar')]))->compile($compiler = new Compiler()); + $this->assertSame('[1 => "foo", 3 => "bar"]', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/AssignNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/AssignNodeTest.php new file mode 100644 index 0000000000000..e054e85cb504b --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/AssignNodeTest.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\AssignNode; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ScalarNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class AssignNodeTest extends TestCase +{ + public function testCompile() + { + (new AssignNode(new VariableNode('foo'), new ScalarNode(true)))->compile($compiler = new Compiler()); + $this->assertSame('$foo = true', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/BinaryNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/BinaryNodeTest.php new file mode 100644 index 0000000000000..40e262512c4d3 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/BinaryNodeTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Php\BinaryNode; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\VariableNode; + +class BinaryNodeTest extends TestCase +{ + public function testCompile() + { + (new BinaryNode('&&', new VariableNode('foo'), new VariableNode('bar')))->compile($compiler = new Compiler()); + $this->assertSame('$foo && $bar', $compiler->source()); + } + + public function testThrowOnInvalidOperator() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "invalid" operator.'); + + new BinaryNode('invalid', new VariableNode('foo'), new VariableNode('bar')); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/CastNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/CastNodeTest.php new file mode 100644 index 0000000000000..312a268d28698 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/CastNodeTest.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\CastNode; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\VariableNode; + +class CastNodeTest extends TestCase +{ + public function testCompile() + { + (new CastNode('array', new VariableNode('foo')))->compile($compiler = new Compiler()); + $this->assertSame('(array) ($foo)', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/ClosureNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/ClosureNodeTest.php new file mode 100644 index 0000000000000..874071023f1b1 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/ClosureNodeTest.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\ArgumentsNode; +use Symfony\Component\Serializer\Php\ClosureNode; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ExpressionNode; +use Symfony\Component\Serializer\Php\ParametersNode; +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Php\ScalarNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class ClosureNodeTest extends TestCase +{ + /** + * @dataProvider compileDataProvider + * + * @param list $body + * @param list $uses + */ + public function testCompile(string $expectedSource, ParametersNode $arguments, ?string $returnType, bool $static, array $body, ?ArgumentsNode $uses) + { + (new ClosureNode($arguments, $returnType, $static, $body, $uses))->compile($compiler = new Compiler()); + $this->assertSame($expectedSource, $compiler->source()); + } + + /** + * @return iterable, 5: ?ArgumentsNode}> + */ + public static function compileDataProvider(): iterable + { + yield [ + << 'string']), + null, + false, + [], + null, + ]; + yield [ + << + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ExpressionNode; +use Symfony\Component\Serializer\Php\ScalarNode; + +class CompilerTest extends TestCase +{ + public function testRaw() + { + $this->assertSame('rawString', (new Compiler())->raw('rawString')->source()); + $this->assertSame('rawString', (new Compiler())->indent()->raw('rawString')->source()); + $this->assertSame(' rawString', (new Compiler())->indent()->raw('rawString', indent: true)->source()); + } + + public function testLine() + { + $compiler = new Compiler(); + + $this->assertSame("lineString\n", $compiler->line('lineString')->source()); + $this->assertSame("lineString\n lineString\n", $compiler->indent()->line('lineString')->source()); + $this->assertSame("lineString\n lineString\nlineString\n", $compiler->outdent()->line('lineString')->source()); + } + + public function testCompile() + { + $compiler = new Compiler(); + + $this->assertSame("\"foo\";\n", $compiler->compile(new ExpressionNode(new ScalarNode('foo')))->source()); + + $compiler->indent(); + + $this->assertSame("\"foo\";\n \"bar\";\n", $compiler->compile(new ExpressionNode(new ScalarNode('bar')))->source()); + } + + public function testReset() + { + $compiler = new Compiler(); + + $this->assertSame("\"foo\";\n", $compiler->compile(new ExpressionNode(new ScalarNode('foo')))->source()); + + $compiler->indent(); + $compiler->reset(); + + $this->assertSame("\"bar\";\n", $compiler->compile(new ExpressionNode(new ScalarNode('bar')))->source()); + } + + public function testSubcompile() + { + $compiler = new Compiler(); + + $this->assertSame("\"foo\";\n", $compiler->compile(new ExpressionNode(new ScalarNode('foo')))->source()); + $this->assertSame("\"bar\";\n", $compiler->subcompile(new ExpressionNode(new ScalarNode('bar')))); + + $this->assertSame("\"foo\";\n", $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/ContinueNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/ContinueNodeTest.php new file mode 100644 index 0000000000000..5d8235269bf85 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/ContinueNodeTest.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ContinueNode; + +class ContinueNodeTest extends TestCase +{ + public function testCompile() + { + (new ContinueNode())->compile($compiler = new Compiler()); + $this->assertSame('continue', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/ExpressionNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/ExpressionNodeTest.php new file mode 100644 index 0000000000000..1d03f603fb2b1 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/ExpressionNodeTest.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ExpressionNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class ExpressionNodeTest extends TestCase +{ + public function testCompile() + { + (new ExpressionNode(new VariableNode('foo')))->compile($compiler = new Compiler()); + $this->assertSame('$foo;'."\n", $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/ForEachNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/ForEachNodeTest.php new file mode 100644 index 0000000000000..3a65c9613acf9 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/ForEachNodeTest.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ExpressionNode; +use Symfony\Component\Serializer\Php\ForEachNode; +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Php\ScalarNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class ForEachNodeTest extends TestCase +{ + /** + * @dataProvider compileDataProvider + * + * @param list $body + */ + public function testCompile(string $expectedSource, PhpNodeInterface $collection, ?VariableNode $keyName, VariableNode $valueName, array $body) + { + (new ForEachNode($collection, $keyName, $valueName, $body))->compile($compiler = new Compiler()); + $this->assertSame($expectedSource, $compiler->source()); + } + + /** + * @return iterable}> + */ + public static function compileDataProvider(): iterable + { + yield [ + << \$fooValue) { + } + + PHP, + new VariableNode('foo'), + new VariableNode('fooKey'), + new VariableNode('fooValue'), + [], + ]; + yield [ + << \$fooValue) { + "foo"; + "bar"; + } + + PHP, + new VariableNode('foo'), + new VariableNode('fooKey'), + new VariableNode('fooValue'), + [new ExpressionNode(new ScalarNode('foo')), new ExpressionNode(new ScalarNode('bar'))], + ]; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/FunctionCallNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/FunctionCallNodeTest.php new file mode 100644 index 0000000000000..5e673078ee076 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/FunctionCallNodeTest.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\ArgumentsNode; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\FunctionCallNode; +use Symfony\Component\Serializer\Php\ScalarNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class FunctionCallNodeTest extends TestCase +{ + public function testCompile() + { + (new FunctionCallNode('fooFunction', new ArgumentsNode([new VariableNode('foo'), new ScalarNode(true)])))->compile($compiler = new Compiler()); + $this->assertSame('fooFunction($foo, true)', $compiler->source()); + + (new FunctionCallNode(new VariableNode('fooFunction'), new ArgumentsNode([])))->compile($compiler = new Compiler()); + $this->assertSame('($fooFunction)()', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/IfNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/IfNodeTest.php new file mode 100644 index 0000000000000..685f7d55aac98 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/IfNodeTest.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ExpressionNode; +use Symfony\Component\Serializer\Php\IfNode; +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Php\ScalarNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class IfNodeTest extends TestCase +{ + /** + * @dataProvider compileDataProvider + * + * @param list $onIf + * @param list $onElse + * @param list}> $elseIfs + */ + public function testCompile(string $expectedSource, PhpNodeInterface $condition, array $onIf, array $onElse, array $elseIfs) + { + (new IfNode($condition, $onIf, $onElse, $elseIfs))->compile($compiler = new Compiler()); + $this->assertSame($expectedSource, $compiler->source()); + } + + /** + * @return iterable, 3: list, 4: list}>}> + */ + public static function compileDataProvider(): iterable + { + yield [ + << new VariableNode('elseIfOne'), 'body' => [new ExpressionNode(new ScalarNode('onElseIfOne'))]], + ['condition' => new VariableNode('elseIfTwo'), 'body' => [new ExpressionNode(new ScalarNode('onElseIfTwo'))]], + ], + ]; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/MethodCallNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/MethodCallNodeTest.php new file mode 100644 index 0000000000000..108d828d6a7b6 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/MethodCallNodeTest.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\ArgumentsNode; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\MethodCallNode; +use Symfony\Component\Serializer\Php\ScalarNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class MethodCallNodeTest extends TestCase +{ + public function testCompile() + { + (new MethodCallNode(new VariableNode('object'), 'method', new ArgumentsNode([new VariableNode('foo'), new ScalarNode(true)])))->compile($compiler = new Compiler()); + $this->assertSame('$object->method($foo, true)', $compiler->source()); + + (new MethodCallNode(new VariableNode('object'), 'method', new ArgumentsNode([]), true))->compile($compiler = new Compiler()); + $this->assertSame('$object::method()', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/NewNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/NewNodeTest.php new file mode 100644 index 0000000000000..663caded04410 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/NewNodeTest.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\ArgumentsNode; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\NewNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class NewNodeTest extends TestCase +{ + public function testCompile() + { + (new NewNode('Foo', new ArgumentsNode([new VariableNode('bar')])))->compile($compiler = new Compiler()); + $this->assertSame('new Foo($bar)', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/OptimizerTest.php b/src/Symfony/Component/Serializer/Tests/Php/OptimizerTest.php new file mode 100644 index 0000000000000..24f815475f98c --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/OptimizerTest.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\ArgumentsNode; +use Symfony\Component\Serializer\Php\ExpressionNode; +use Symfony\Component\Serializer\Php\FunctionCallNode; +use Symfony\Component\Serializer\Php\Optimizer; +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Php\ScalarNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class OptimizerTest extends TestCase +{ + /** + * @dataProvider mergeStringFwritesDataProvider + * + * @param list $expectedNodes + * @param list $nodes + */ + public function testMergeStringFwrites(array $expectedNodes, array $nodes) + { + $this->assertEquals($expectedNodes, (new Optimizer())->optimize($nodes)); + } + + /** + * @return iterable, 1: list}> + */ + public static function mergeStringFwritesDataProvider(): iterable + { + $createFwriteExpression = fn (PhpNodeInterface $content) => new ExpressionNode(new FunctionCallNode( + '\fwrite', + new ArgumentsNode([new VariableNode('resource'), $content]), + )); + + yield [[ + $createFwriteExpression(new ScalarNode('foobar')), + ], [ + $createFwriteExpression(new ScalarNode('foo')), + $createFwriteExpression(new ScalarNode('bar')), + ]]; + + yield [[ + 'foo' => $createFwriteExpression(new ScalarNode('foo')), + 'bar' => $createFwriteExpression(new ScalarNode('bar')), + ], [ + 'foo' => $createFwriteExpression(new ScalarNode('foo')), + 'bar' => $createFwriteExpression(new ScalarNode('bar')), + ]]; + + yield [[ + $createFwriteExpression(new ScalarNode('foo')), + $createFwriteExpression(new VariableNode('bar')), + $createFwriteExpression(new ScalarNode('baz')), + ], [ + $createFwriteExpression(new ScalarNode('foo')), + $createFwriteExpression(new VariableNode('bar')), + $createFwriteExpression(new ScalarNode('baz')), + ]]; + + yield [[ + new ExpressionNode(new FunctionCallNode('fooFunction', new ArgumentsNode([]))), + new ExpressionNode(new FunctionCallNode('barFunction', new ArgumentsNode([]))), + $createFwriteExpression(new ScalarNode('baz')), + ], [ + new ExpressionNode(new FunctionCallNode('fooFunction', new ArgumentsNode([]))), + new ExpressionNode(new FunctionCallNode('barFunction', new ArgumentsNode([]))), + $createFwriteExpression(new ScalarNode('baz')), + ]]; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/ParametersNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/ParametersNodeTest.php new file mode 100644 index 0000000000000..d9e386459419f --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/ParametersNodeTest.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ParametersNode; + +class ParametersNodeTest extends TestCase +{ + public function testCompile() + { + (new ParametersNode(['foo' => '?int']))->compile($compiler = new Compiler()); + $this->assertSame('?int $foo', $compiler->source()); + + (new ParametersNode(['foo' => 'string', '&bar' => null]))->compile($compiler = new Compiler()); + $this->assertSame('string $foo, &$bar', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/PhpDocNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/PhpDocNodeTest.php new file mode 100644 index 0000000000000..969520175bcb4 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/PhpDocNodeTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\PhpDocNode; + +class PhpDocNodeTest extends TestCase +{ + public function testCompile() + { + (new PhpDocNode(['@param string foo', '', '@return bool']))->compile($compiler = new Compiler()); + $this->assertSame( + <<source(), + ); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/PropertyNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/PropertyNodeTest.php new file mode 100644 index 0000000000000..8972ff2ad1a86 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/PropertyNodeTest.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\PropertyNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class PropertyNodeTest extends TestCase +{ + public function testCompile() + { + (new PropertyNode(new VariableNode('foo'), 'bar'))->compile($compiler = new Compiler()); + $this->assertSame('$foo->bar', $compiler->source()); + + (new PropertyNode(new VariableNode('foo'), 'bar', true))->compile($compiler = new Compiler()); + $this->assertSame('$foo::$bar', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/ReturnNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/ReturnNodeTest.php new file mode 100644 index 0000000000000..9de2b55a6414b --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/ReturnNodeTest.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ReturnNode; +use Symfony\Component\Serializer\Php\ScalarNode; + +class ReturnNodeTest extends TestCase +{ + public function testCompile() + { + (new ReturnNode(new ScalarNode(true)))->compile($compiler = new Compiler()); + $this->assertSame('return true', $compiler->source()); + + (new ReturnNode(null))->compile($compiler = new Compiler()); + $this->assertSame('return', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/ScalarNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/ScalarNodeTest.php new file mode 100644 index 0000000000000..94e28f647cafc --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/ScalarNodeTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ScalarNode; + +class ScalarNodeTest extends TestCase +{ + /** + * @dataProvider compileDataProvider + */ + public function testCompile(string $expectedSource, mixed $scalar) + { + (new ScalarNode($scalar))->compile($compiler = new Compiler()); + $this->assertSame($expectedSource, $compiler->source()); + } + + /** + * @return iterable + */ + public static function compileDataProvider(): iterable + { + yield ['null', null]; + yield ['123', 123]; + yield ['123.456', 123.456]; + yield ['true', true]; + yield ['false', false]; + yield ['"string"', 'string']; + yield ['"\"string\""', '"string"']; + yield ['"str\\\\ing"', 'str\ing']; + } + + public function testCannotCompileNotScalar() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Given value is not a scalar. Got "array".'); + + (new ScalarNode(['foo']))->compile($compiler = new Compiler()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/TemplateStringNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/TemplateStringNodeTest.php new file mode 100644 index 0000000000000..80a3308e66115 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/TemplateStringNodeTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\TemplateStringNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class TemplateStringNodeTest extends TestCase +{ + /** + * @param list $parts + * + * @dataProvider compileDataProvider + */ + public function testCompile(string $expectedSource, array $parts) + { + (new TemplateStringNode(...$parts))->compile($compiler = new Compiler()); + $this->assertSame($expectedSource, $compiler->source()); + } + + /** + * @return iterable}> + */ + public static function compileDataProvider(): iterable + { + yield ['""', []]; + yield ['"foobar"', ['foo', 'bar']]; + yield ['"foo{$bar}baz"', ['foo', new VariableNode('bar'), 'baz']]; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/TernaryConditionNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/TernaryConditionNodeTest.php new file mode 100644 index 0000000000000..b7bfebc01a048 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/TernaryConditionNodeTest.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\TernaryConditionNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class TernaryConditionNodeTest extends TestCase +{ + public function testCompile() + { + (new TernaryConditionNode(new VariableNode('foo'), new VariableNode('trueFoo'), new VariableNode('falseFoo')))->compile($compiler = new Compiler()); + $this->assertSame('($foo ? $trueFoo : $falseFoo)', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/ThrowNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/ThrowNodeTest.php new file mode 100644 index 0000000000000..b9807fd69a924 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/ThrowNodeTest.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ThrowNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class ThrowNodeTest extends TestCase +{ + public function testCompile() + { + (new ThrowNode(new VariableNode('foo')))->compile($compiler = new Compiler()); + $this->assertSame('throw $foo', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/TryCatchNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/TryCatchNodeTest.php new file mode 100644 index 0000000000000..1c0f32d47a9b8 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/TryCatchNodeTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\AssignNode; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ExpressionNode; +use Symfony\Component\Serializer\Php\ParametersNode; +use Symfony\Component\Serializer\Php\PhpNodeInterface; +use Symfony\Component\Serializer\Php\ScalarNode; +use Symfony\Component\Serializer\Php\TryCatchNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class TryCatchNodeTest extends TestCase +{ + /** + * @dataProvider compileDataProvider + * + * @param list $tryNodes + * @param list $catchNodes + */ + public function testCompile(string $expectedSource, array $tryNodes, array $catchNodes, ParametersNode $catchParameters) + { + (new TryCatchNode($tryNodes, $catchNodes, $catchParameters))->compile($compiler = new Compiler()); + $this->assertSame($expectedSource, $compiler->source()); + } + + /** + * @return iterable, 2: list, 3: ParametersNode}> + */ + public static function compileDataProvider(): iterable + { + yield [ + << null]), + ]; + yield [ + << 'Exception']), + ]; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/UnaryNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/UnaryNodeTest.php new file mode 100644 index 0000000000000..db461df2f78c1 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/UnaryNodeTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\UnaryNode; +use Symfony\Component\Serializer\Php\VariableNode; + +class UnaryNodeTest extends TestCase +{ + public function testCompile() + { + (new UnaryNode('!', new VariableNode('foo')))->compile($compiler = new Compiler()); + $this->assertSame('!$foo', $compiler->source()); + } + + public function testThrowOnInvalidOperator() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "invalid" operator.'); + + new UnaryNode('invalid', new VariableNode('foo')); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/VariableNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/VariableNodeTest.php new file mode 100644 index 0000000000000..340e5b45f211d --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/VariableNodeTest.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\VariableNode; + +class VariableNodeTest extends TestCase +{ + public function testCompile() + { + (new VariableNode('foo'))->compile($compiler = new Compiler()); + $this->assertSame('$foo', $compiler->source()); + + (new VariableNode('foo', byReference: true))->compile($compiler = new Compiler()); + $this->assertSame('&$foo', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Php/YieldNodeTest.php b/src/Symfony/Component/Serializer/Tests/Php/YieldNodeTest.php new file mode 100644 index 0000000000000..5a056c70062ff --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Php/YieldNodeTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Php; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Php\Compiler; +use Symfony\Component\Serializer\Php\ScalarNode; +use Symfony\Component\Serializer\Php\YieldNode; + +class YieldNodeTest extends TestCase +{ + public function testCompile() + { + (new YieldNode(new ScalarNode(true)))->compile($compiler = new Compiler()); + $this->assertSame('yield true', $compiler->source()); + + (new YieldNode(new ScalarNode('value'), new ScalarNode('key')))->compile($compiler = new Compiler()); + + $this->assertSame('yield "key" => "value"', $compiler->source()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Serialize/DataModel/DataModelBuilderTest.php b/src/Symfony/Component/Serializer/Tests/Serialize/DataModel/DataModelBuilderTest.php new file mode 100644 index 0000000000000..0ca744d8a4d2f --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Serialize/DataModel/DataModelBuilderTest.php @@ -0,0 +1,241 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Serialize\DataModel; + +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Exception\MaxDepthException; +use Symfony\Component\Serializer\Php\ArgumentsNode; +use Symfony\Component\Serializer\Php\FunctionCallNode; +use Symfony\Component\Serializer\Php\MethodCallNode; +use Symfony\Component\Serializer\Php\PropertyNode; +use Symfony\Component\Serializer\Php\ScalarNode as PhpScalarNode; +use Symfony\Component\Serializer\Php\VariableNode; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Serialize\DataModel\CollectionNode; +use Symfony\Component\Serializer\Serialize\DataModel\DataModelBuilder; +use Symfony\Component\Serializer\Serialize\DataModel\DataModelNodeInterface; +use Symfony\Component\Serializer\Serialize\DataModel\ObjectNode; +use Symfony\Component\Serializer\Serialize\DataModel\ScalarNode; +use Symfony\Component\Serializer\Serialize\Mapping\PropertyMetadata; +use Symfony\Component\Serializer\Serialize\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithAttributesUsingServices; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithFormatterAttributes; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithMethods; +use Symfony\Component\Serializer\Type\Type; +use Symfony\Contracts\Service\ServiceLocatorTrait; + +class DataModelBuilderTest extends TestCase +{ + /** + * @dataProvider buildDataModelDataProvider + */ + public function testBuildDataModel(Type $type, DataModelNodeInterface $dataModel) + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader(), self::runtimeServices()); + + $this->assertEquals($dataModel, $dataModelBuilder->build($type, new VariableNode('data'), new SerializeConfig())); + } + + /** + * @return iterable + */ + public static function buildDataModelDataProvider(): iterable + { + $accessor = new VariableNode('data'); + + yield [Type::int(), new ScalarNode($accessor, Type::int())]; + yield [Type::array(), new ScalarNode($accessor, Type::array())]; + yield [Type::object(), new ScalarNode($accessor, Type::object())]; + yield [Type::class(\stdClass::class), new ScalarNode($accessor, Type::object())]; + yield [Type::union(Type::int(), Type::string()), new ScalarNode($accessor, Type::union(Type::int(), Type::string()))]; + yield [Type::intersection(Type::int(), Type::string()), new ScalarNode($accessor, Type::intersection(Type::int(), Type::string()))]; + + yield [Type::list(Type::string()), new CollectionNode($accessor, Type::list(Type::string()), new ScalarNode(new VariableNode('value_0'), Type::string()))]; + yield [Type::dict(Type::string()), new CollectionNode($accessor, Type::dict(Type::string()), new ScalarNode(new VariableNode('value_0'), Type::string()))]; + + yield [Type::class(self::class), new ObjectNode($accessor, Type::class(self::class), [], false)]; + } + + public function testThrowWhenMaxDepthIsReached() + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader([ + new PropertyMetadata('foo', Type::class(self::class), []), + ]), self::runtimeServices()); + + $this->expectException(MaxDepthException::class); + $dataModelBuilder->build(Type::class(self::class), new VariableNode('data'), new SerializeConfig()); + } + + public function testCallPropertyMetadataLoaderWithProperContext() + { + $config = new SerializeConfig(); + $type = Type::class(self::class, true, [Type::int()]); + + $propertyMetadataLoader = $this->createMock(PropertyMetadataLoaderInterface::class); + $propertyMetadataLoader->expects($this->once()) + ->method('load') + ->with(self::class, $config, [ + 'original_type' => $type, + 'depth_counters' => [$type->className() => 1], + ]) + ->willReturn([]); + + $dataModelBuilder = new DataModelBuilder($propertyMetadataLoader, self::runtimeServices()); + $dataModelBuilder->build($type, new VariableNode('data'), $config); + } + + public function testPropertyWithSimpleAccessor() + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader([ + new PropertyMetadata('foo', Type::int(), []), + ]), self::runtimeServices()); + + /** @var ObjectNode $dataModel */ + $dataModel = $dataModelBuilder->build(Type::class(self::class), new VariableNode('data'), new SerializeConfig()); + + $this->assertEquals(new PropertyNode(new VariableNode('data'), 'foo'), $dataModel->properties[0]->accessor); + } + + public function testPropertyWithCustomAccessors() + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader([ + new PropertyMetadata('foo', Type::int(), ['strtoupper', DummyWithFormatterAttributes::doubleAndCastToString(...)]), + ]), self::runtimeServices()); + + /** @var ObjectNode $dataModel */ + $dataModel = $dataModelBuilder->build(Type::class(self::class), new VariableNode('data'), new SerializeConfig()); + + $this->assertEquals( + new FunctionCallNode( + sprintf('%s::doubleAndCastToString', DummyWithFormatterAttributes::class), + new ArgumentsNode([new FunctionCallNode('strtoupper', new ArgumentsNode([new PropertyNode(new VariableNode('data'), 'foo')]))]), + ), + $dataModel->properties[0]->accessor, + ); + } + + public function testPropertyWithAccessorWithConfig() + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader([ + new PropertyMetadata( + 'foo', + Type::int(), + [DummyWithFormatterAttributes::doubleAndCastToStringWithConfig(...)], + ), + ]), self::runtimeServices()); + + /** @var ObjectNode $dataModel */ + $dataModel = $dataModelBuilder->build(Type::class(self::class), new VariableNode('data'), new SerializeConfig()); + + $this->assertEquals( + new FunctionCallNode(sprintf('%s::doubleAndCastToStringWithConfig', DummyWithFormatterAttributes::class), new ArgumentsNode([ + new PropertyNode(new VariableNode('data'), 'foo'), + new VariableNode('config'), + ])), + $dataModel->properties[0]->accessor, + ); + } + + public function testPropertyWithFormatterWithRuntimeServices() + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader([ + new PropertyMetadata( + 'foo', + Type::int(), + [DummyWithAttributesUsingServices::serviceAndSerializeConfig(...)], + ), + ]), self::runtimeServices([ + sprintf('%s::serviceAndSerializeConfig[serializer]', DummyWithAttributesUsingServices::class) => 'useless', + sprintf('%s::serviceAndSerializeConfig[typeExtractor]', DummyWithAttributesUsingServices::class) => 'useless', + ])); + + /** @var ObjectNode $dataModel */ + $dataModel = $dataModelBuilder->build(Type::class(self::class), new VariableNode('data'), new SerializeConfig()); + + $this->assertEquals( + new FunctionCallNode(sprintf('%s::serviceAndSerializeConfig', DummyWithAttributesUsingServices::class), new ArgumentsNode([ + new PropertyNode(new VariableNode('data'), 'foo'), + new MethodCallNode( + new VariableNode('services'), + 'get', + new ArgumentsNode([new PhpScalarNode(sprintf('%s::serviceAndSerializeConfig[serializer]', DummyWithAttributesUsingServices::class))]), + ), + new MethodCallNode( + new VariableNode('services'), + 'get', + new ArgumentsNode([new PhpScalarNode(sprintf('%s::serviceAndSerializeConfig[typeExtractor]', DummyWithAttributesUsingServices::class))]), + ), + new VariableNode('config'), + ])), + $dataModel->properties[0]->accessor, + ); + } + + public function testPropertyWithConstAccessor() + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader([ + new PropertyMetadata('foo', Type::int(), [DummyWithMethods::const(...)]), + ]), self::runtimeServices()); + + /** @var ObjectNode $dataModel */ + $dataModel = $dataModelBuilder->build(Type::class(self::class), new VariableNode('data'), new SerializeConfig()); + + $this->assertEquals( + new FunctionCallNode(sprintf('%s::const', DummyWithMethods::class), new ArgumentsNode([])), + $dataModel->properties[0]->accessor, + ); + } + + public function testPropertyWithFormatterWithInvalidArgument() + { + $dataModelBuilder = new DataModelBuilder(self::propertyMetadataLoader([ + new PropertyMetadata( + 'foo', + Type::class(DummyWithAttributesUsingServices::class), + [DummyWithAttributesUsingServices::serviceAndSerializeConfig(...)], + ), + ]), self::runtimeServices()); + + $this->expectException(LogicException::class); + + $dataModelBuilder->build(Type::class(self::class), new VariableNode('data'), new SerializeConfig()); + } + + /** + * @param array $propertiesMetadata + */ + private static function propertyMetadataLoader(array $propertiesMetadata = []): PropertyMetadataLoaderInterface + { + return new class($propertiesMetadata) implements PropertyMetadataLoaderInterface { + public function __construct(private readonly array $propertiesMetadata) + { + } + + public function load(string $className, SerializeConfig $config, array $context): array + { + return $this->propertiesMetadata; + } + }; + } + + /** + * @param array $runtimeServices + */ + private static function runtimeServices(array $runtimeServices = []): ContainerInterface + { + return new class($runtimeServices) implements ContainerInterface { + use ServiceLocatorTrait; + }; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Serialize/Mapping/AttributePropertyMetadataLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Serialize/Mapping/AttributePropertyMetadataLoaderTest.php new file mode 100644 index 0000000000000..55a38fe5bfc2f --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Serialize/Mapping/AttributePropertyMetadataLoaderTest.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Serialize\Mapping; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Serialize\Mapping\AttributePropertyMetadataLoader; +use Symfony\Component\Serializer\Serialize\Mapping\PropertyMetadata; +use Symfony\Component\Serializer\Serialize\Mapping\PropertyMetadataLoader; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithFormatterAttributes; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithGroups; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithMaxDepthAttribute; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithNameAttributes; +use Symfony\Component\Serializer\Type\PhpstanTypeExtractor; +use Symfony\Component\Serializer\Type\ReflectionTypeExtractor; +use Symfony\Component\Serializer\Type\Type; + +class AttributePropertyMetadataLoaderTest extends TestCase +{ + public function testFilterPropertiesByGroups() + { + $typeExtractor = new PhpstanTypeExtractor(new ReflectionTypeExtractor()); + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader($typeExtractor), $typeExtractor); + + $this->assertSame( + ['none', 'one', 'oneAndTwo', 'twoAndThree'], + array_keys($loader->load(DummyWithGroups::class, new SerializeConfig(), [])), + ); + + $this->assertSame( + ['one', 'oneAndTwo'], + array_keys($loader->load(DummyWithGroups::class, (new SerializeConfig())->withGroups(['one']), [])), + ); + + $this->assertSame( + ['oneAndTwo', 'twoAndThree'], + array_keys($loader->load(DummyWithGroups::class, (new SerializeConfig())->withGroups(['two']), [])), + ); + + $this->assertSame( + ['twoAndThree'], + array_keys($loader->load(DummyWithGroups::class, (new SerializeConfig())->withGroups(['three']), [])), + ); + + $this->assertSame([], $loader->load(DummyWithGroups::class, (new SerializeConfig())->withGroups(['other']), [])); + } + + public function testRetrieveSerializedName() + { + $typeExtractor = new PhpstanTypeExtractor(new ReflectionTypeExtractor()); + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader($typeExtractor), $typeExtractor); + + $this->assertSame(['@id', 'name'], array_keys($loader->load(DummyWithNameAttributes::class, new SerializeConfig(), []))); + } + + public function testRetrieveSerializeFormatter() + { + $typeExtractor = new PhpstanTypeExtractor(new ReflectionTypeExtractor()); + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader($typeExtractor), $typeExtractor); + + $this->assertEquals([ + 'id' => new PropertyMetadata('id', Type::string(), [DummyWithFormatterAttributes::doubleAndCastToString(...)]), + 'name' => new PropertyMetadata('name', Type::string(), []), + ], $loader->load(DummyWithFormatterAttributes::class, new SerializeConfig(), [])); + } + + public function testRetrieveMaxDepthFormatter() + { + $typeExtractor = new PhpstanTypeExtractor(new ReflectionTypeExtractor()); + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader($typeExtractor), $typeExtractor); + + $this->assertEquals([ + 'id' => new PropertyMetadata('id', Type::int(), []), + ], $loader->load(DummyWithMaxDepthAttribute::class, new SerializeConfig(), [])); + + $this->assertEquals([ + 'id' => new PropertyMetadata('id', Type::bool(), [DummyWithMaxDepthAttribute::boolean(...)]), + ], $loader->load(DummyWithMaxDepthAttribute::class, new SerializeConfig(), [ + 'depth_counters' => [DummyWithMaxDepthAttribute::class => 256], + ])); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Serialize/Mapping/PropertyMetadataLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Serialize/Mapping/PropertyMetadataLoaderTest.php new file mode 100644 index 0000000000000..4f03d7ab5293e --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Serialize/Mapping/PropertyMetadataLoaderTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Serialize\Mapping; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Serialize\Mapping\PropertyMetadata; +use Symfony\Component\Serializer\Serialize\Mapping\PropertyMetadataLoader; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Type\PhpstanTypeExtractor; +use Symfony\Component\Serializer\Type\ReflectionTypeExtractor; +use Symfony\Component\Serializer\Type\Type; + +class PropertyMetadataLoaderTest extends TestCase +{ + public function testExtractPropertyMetadata() + { + $loader = new PropertyMetadataLoader(new PhpstanTypeExtractor(new ReflectionTypeExtractor())); + + $this->assertEquals([ + 'id' => new PropertyMetadata('id', Type::int(), []), + 'name' => new PropertyMetadata('name', Type::string(), []), + ], $loader->load(ClassicDummy::class, new SerializeConfig(), [])); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Serialize/Mapping/PropertyMetadataTest.php b/src/Symfony/Component/Serializer/Tests/Serialize/Mapping/PropertyMetadataTest.php new file mode 100644 index 0000000000000..db3a505ecb7cf --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Serialize/Mapping/PropertyMetadataTest.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Serialize\Mapping; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Serialize\Mapping\PropertyMetadata; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithMethods; +use Symfony\Component\Serializer\Type\Type; + +class PropertyMetadataTest extends TestCase +{ + public function testThrowOnNonStaticFormatter() + { + $this->expectException(InvalidArgumentException::class); + new PropertyMetadata('useless', Type::mixed(), [(new DummyWithMethods())->nonStatic(...)]); + } + + public function testThrowOnNonAnonymousFormatter() + { + $this->expectException(InvalidArgumentException::class); + new PropertyMetadata('useless', Type::mixed(), [fn () => 'useless']); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Serialize/Mapping/TypePropertyMetadataLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Serialize/Mapping/TypePropertyMetadataLoaderTest.php new file mode 100644 index 0000000000000..d2700f2fea427 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Serialize/Mapping/TypePropertyMetadataLoaderTest.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Serialize\Mapping; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Serialize\Mapping\PropertyMetadata; +use Symfony\Component\Serializer\Serialize\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\Serializer\Serialize\Mapping\TypePropertyMetadataLoader; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithGenerics; +use Symfony\Component\Serializer\Type\PhpstanTypeExtractor; +use Symfony\Component\Serializer\Type\Type; +use Symfony\Component\Serializer\Type\TypeExtractorInterface; + +class TypePropertyMetadataLoaderTest extends TestCase +{ + public function testCastDateTimeToString() + { + $loader = new TypePropertyMetadataLoader(self::propertyMetadataLoader([ + 'foo' => new PropertyMetadata('foo', Type::class(\DateTimeImmutable::class), []), + ]), $this->createStub(TypeExtractorInterface::class)); + + $metadata = $loader->load(self::class, new SerializeConfig(), ['original_type' => Type::fromString('useless')]); + + $this->assertEquals([ + 'foo' => new PropertyMetadata('foo', Type::string(), [ + \Closure::fromCallable(TypePropertyMetadataLoader::castDateTimeToString(...)), + ]), + ], $metadata); + + $formatter = $metadata['foo']->formatters()[0]; + + $this->assertEquals( + '2023-07-26T00:00:00+00:00', + $formatter(new \DateTimeImmutable('2023-07-26'), new SerializeConfig()), + ); + + $this->assertEquals( + '26/07/2023 00:00:00', + $formatter((new \DateTimeImmutable('2023-07-26'))->setTime(0, 0), (new SerializeConfig())->withDateTimeFormat('d/m/Y H:i:s')), + ); + } + + public function testReplaceGenerics() + { + $loader = new TypePropertyMetadataLoader(self::propertyMetadataLoader([ + 'foo' => new PropertyMetadata('foo', Type::fromString('T'), []), + ]), new PhpstanTypeExtractor($this->createStub(TypeExtractorInterface::class))); + + $metadata = $loader->load( + DummyWithGenerics::class, + new SerializeConfig(), + ['original_type' => Type::class(DummyWithGenerics::class, genericParameterTypes: [Type::int()])], + ); + + $this->assertEquals([ + 'foo' => new PropertyMetadata('foo', Type::int(), []), + ], $metadata); + } + + public function testReplaceGenericsAndCastDateTimeToString() + { + $loader = new TypePropertyMetadataLoader(self::propertyMetadataLoader([ + 'foo' => new PropertyMetadata('foo', Type::fromString('T'), []), + ]), new PhpstanTypeExtractor($this->createStub(TypeExtractorInterface::class))); + + $metadata = $loader->load( + DummyWithGenerics::class, + new SerializeConfig(), + ['original_type' => Type::class(DummyWithGenerics::class, genericParameterTypes: [Type::class(\DateTimeImmutable::class)])], + ); + + $this->assertEquals([ + 'foo' => new PropertyMetadata('foo', Type::string(), [ + \Closure::fromCallable(TypePropertyMetadataLoader::castDateTimeToString(...)), + ]), + ], $metadata); + } + + /** + * @param array $propertiesMetadata + */ + private static function propertyMetadataLoader(array $propertiesMetadata = []): PropertyMetadataLoaderInterface + { + return new class($propertiesMetadata) implements PropertyMetadataLoaderInterface { + public function __construct(private readonly array $propertiesMetadata) + { + } + + public function load(string $className, SerializeConfig $config, array $context): array + { + return $this->propertiesMetadata; + } + }; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Serialize/SerializerTest.php b/src/Symfony/Component/Serializer/Tests/Serialize/SerializerTest.php new file mode 100644 index 0000000000000..56710ecfe3586 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Serialize/SerializerTest.php @@ -0,0 +1,232 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Serialize; + +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\Encoder\CsvEncoder; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Serialize\DataModel\DataModelBuilder; +use Symfony\Component\Serializer\Serialize\DataModel\DataModelBuilderInterface; +use Symfony\Component\Serializer\Serialize\Mapping\AttributePropertyMetadataLoader; +use Symfony\Component\Serializer\Serialize\Mapping\PropertyMetadataLoader; +use Symfony\Component\Serializer\Serialize\Mapping\TypePropertyMetadataLoader; +use Symfony\Component\Serializer\Serialize\Serializer; +use Symfony\Component\Serializer\Serialize\SerializerInterface; +use Symfony\Component\Serializer\Serialize\Template\JsonTemplateGenerator; +use Symfony\Component\Serializer\Serialize\Template\NormalizerEncoderTemplateGenerator; +use Symfony\Component\Serializer\Serialize\Template\Template; +use Symfony\Component\Serializer\Stream\MemoryStream; +use Symfony\Component\Serializer\Template\TemplateVariationExtractor; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithFormatterAttributes; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithGroups; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithNameAttributes; +use Symfony\Component\Serializer\Tests\Fixtures\Enum\DummyBackedEnum; +use Symfony\Component\Serializer\Type\PhpstanTypeExtractor; +use Symfony\Component\Serializer\Type\ReflectionTypeExtractor; +use Symfony\Component\Serializer\Type\Type; +use Symfony\Contracts\Service\ServiceLocatorTrait; + +class SerializerTest extends TestCase +{ + private string $cacheDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->cacheDir = sprintf('%s/symfony_serializer_template', sys_get_temp_dir()); + + if (is_dir($this->cacheDir)) { + array_map('unlink', glob($this->cacheDir.'/*')); + rmdir($this->cacheDir); + } + } + + public function testSerializeWithResourceOutput() + { + $this->serializer()->serialize(true, 'json', $output = new MemoryStream()); + rewind($output->resource()); + + $this->assertSame('true', stream_get_contents($output->resource())); + } + + public function testSerializeScalar() + { + $this->assertSame('null', $this->serializer()->serialize(null, 'json')); + $this->assertSame('true', $this->serializer()->serialize(true, 'json')); + $this->assertSame( + '[{"foo":1,"bar":2},{"foo":3}]', + $this->serializer()->serialize([['foo' => 1, 'bar' => 2], ['foo' => 3]], 'json'), + ); + + return; + $this->assertEquals( + '{"foo":"bar"}', + $this->serializer()->serialize((object) ['foo' => 'bar'], 'json'), + ); + $this->assertEquals( + 'ONE', + $this->serializer()->serialize(DummyBackedEnum::ONE, 'json'), + ); + } + + public function testOverrideType() + { + $this->assertSame('{"foo":"bar"}', $this->serializer()->serialize(['foo' => 'bar'], 'json')); + $this->assertSame('["bar"]', $this->serializer()->serialize(['foo' => 'bar'], 'json', config: (new SerializeConfig())->withType(Type::list(Type::string())))); + } + + public function testSerializeObject() + { + $dummy = new ClassicDummy(); + $dummy->id = 10; + $dummy->name = 'dummy name'; + + $this->assertEquals('{"id":10,"name":"dummy name"}', $this->serializer()->serialize($dummy, 'json')); + } + + public function testSerializeObjectWithSerializedName() + { + $dummy = new DummyWithNameAttributes(); + $dummy->id = 10; + $dummy->name = 'dummy name'; + + $this->assertEquals( + '{"@id":10,"name":"dummy name"}', + $this->serializer()->serialize($dummy, 'json'), + ); + } + + public function testSerializeObjectWithSerializeFormatter() + { + $dummy = new DummyWithFormatterAttributes(); + $dummy->id = 10; + $dummy->name = 'dummy name'; + + $this->assertEquals( + '{"id":"20","name":"dummy name"}', + $this->serializer()->serialize($dummy, 'json'), + ); + } + + public function testSerializeObjectWithGroupsAttribute() + { + $dummyWithoutGroup = new DummyWithGroups(); + $dummyWithoutGroup->none = 'set'; + $dummyWithoutGroup->one = 'set'; + $dummyWithoutGroup->oneAndTwo = 'set'; + $dummyWithoutGroup->twoAndThree = 'set'; + + $this->assertEquals( + '{"none":"set","one":"set","oneAndTwo":"set","twoAndThree":"set"}', + $this->serializer()->serialize($dummyWithoutGroup, 'json'), + ); + + $dummyWithGroupOne = new DummyWithGroups(); + $dummyWithGroupOne->one = 'set'; + $dummyWithGroupOne->oneAndTwo = 'set'; + + $this->assertEquals( + '{"one":"set","oneAndTwo":"set"}', + $this->serializer()->serialize($dummyWithGroupOne, 'json', config: (new SerializeConfig())->withGroups('one')), + ); + + $dummyWithGroupTwo = new DummyWithGroups(); + $dummyWithGroupTwo->oneAndTwo = 'set'; + $dummyWithGroupTwo->twoAndThree = 'set'; + + $this->assertEquals( + '{"oneAndTwo":"set","twoAndThree":"set"}', + $this->serializer()->serialize($dummyWithGroupTwo, 'json', config: (new SerializeConfig())->withGroups('two')), + ); + + $this->assertEquals( + '{}', + $this->serializer()->serialize(new DummyWithGroups(), 'json', config: (new SerializeConfig())->withGroups('other')), + ); + } + + public function testCreateCacheFile() + { + $this->serializer()->serialize(true, 'json'); + + $this->assertFileExists($this->cacheDir); + $this->assertCount(1, glob($this->cacheDir.'/*')); + } + + public function testCreateCacheFileOnlyIfNotExists() + { + $template = new Template( + new TemplateVariationExtractor(), + $this->createStub(DataModelBuilderInterface::class), + [], + $this->cacheDir, + false, + ); + if (!file_exists($this->cacheDir)) { + mkdir($this->cacheDir, recursive: true); + } + + $cacheFilename = $template->path(Type::bool(), 'json', new SerializeConfig()); + file_put_contents($cacheFilename, 'assertSame('CACHED', $this->serializer()->serialize(true, 'json')); + } + + public function testRecreateCacheFileIfForceGenerateTemplate() + { + $template = new Template( + new TemplateVariationExtractor(), + $this->createStub(DataModelBuilderInterface::class), + [], + $this->cacheDir, + false, + ); + if (!file_exists($this->cacheDir)) { + mkdir($this->cacheDir, recursive: true); + } + + $cacheFilename = $template->path(Type::bool(), 'json', new SerializeConfig()); + file_put_contents($cacheFilename, 'assertSame('true', $this->serializer()->serialize(true, 'json', config: (new SerializeConfig())->withForceGenerateTemplate())); + } + + /** + * @param array $runtimeServices + */ + private function serializer(array $runtimeServices = []): SerializerInterface + { + $typeExtractor = new PhpstanTypeExtractor(new ReflectionTypeExtractor()); + $propertyMetadataLoader = new TypePropertyMetadataLoader( + new AttributePropertyMetadataLoader(new PropertyMetadataLoader($typeExtractor), $typeExtractor), + $typeExtractor, + ); + $runtimeServicesLocator = new class($runtimeServices) implements ContainerInterface { + use ServiceLocatorTrait; + }; + $dataModeBuilder = new DataModelBuilder($propertyMetadataLoader, $runtimeServicesLocator); + $template = new Template( + new TemplateVariationExtractor(), + $dataModeBuilder, + [ + 'json' => new JsonTemplateGenerator(), + 'csv' => new NormalizerEncoderTemplateGenerator(CsvEncoder::class), + ], + $this->cacheDir, + ); + + return new Serializer($template, $runtimeServicesLocator, $this->cacheDir); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Serialize/Template/TemplateTest.php b/src/Symfony/Component/Serializer/Tests/Serialize/Template/TemplateTest.php new file mode 100644 index 0000000000000..cb8ca0d02ed27 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Serialize/Template/TemplateTest.php @@ -0,0 +1,161 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Serialize\Template; + +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\Exception\UnsupportedFormatException; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Serialize\DataModel\DataModelBuilder; +use Symfony\Component\Serializer\Serialize\DataModel\DataModelBuilderInterface; +use Symfony\Component\Serializer\Serialize\Mapping\AttributePropertyMetadataLoader; +use Symfony\Component\Serializer\Serialize\Mapping\PropertyMetadataLoader; +use Symfony\Component\Serializer\Serialize\Mapping\TypePropertyMetadataLoader; +use Symfony\Component\Serializer\Serialize\Template\JsonTemplateGenerator; +use Symfony\Component\Serializer\Serialize\Template\NormalizerEncoderTemplateGenerator; +use Symfony\Component\Serializer\Serialize\Template\Template; +use Symfony\Component\Serializer\Template\GroupTemplateVariation; +use Symfony\Component\Serializer\Template\TemplateVariation; +use Symfony\Component\Serializer\Template\TemplateVariationExtractorInterface; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Enum\DummyBackedEnum; +use Symfony\Component\Serializer\Type\PhpstanTypeExtractor; +use Symfony\Component\Serializer\Type\ReflectionTypeExtractor; +use Symfony\Component\Serializer\Type\Type; + +class TemplateTest extends TestCase +{ + private string $cacheDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->cacheDir = sprintf('%s/symfony_serializer_template', sys_get_temp_dir()); + + if (is_dir($this->cacheDir)) { + array_map('unlink', glob($this->cacheDir.'/*')); + rmdir($this->cacheDir); + } + } + + /** + * @dataProvider templatePathDataProvider + * + * @param list $variations + */ + public function testTemplatePath(string $expectedFilename, Type $type, array $variations, bool $lazy) + { + $templateVariationExtractor = $this->createStub(TemplateVariationExtractorInterface::class); + $templateVariationExtractor->method('extractVariationsFromConfig')->willReturn($variations); + + $template = new Template( + $templateVariationExtractor, + $this->createStub(DataModelBuilderInterface::class), + [], + $this->cacheDir, + false, + ); + + $this->assertSame(sprintf('%s/%s', $this->cacheDir, $expectedFilename), $template->path($type, 'format', new SerializeConfig())); + } + + /** + * @return iterable, 3: bool}> + */ + public static function templatePathDataProvider(): iterable + { + yield ['7617cc4b435dae7c97211c6082923b47.serialize.format.php', Type::int(), [], false]; + yield ['6e77b03690271cbee671df141e635536.serialize.format.php', Type::int(nullable: true), [], false]; + yield ['070660c7e72aa3e14a93c1039279afb6.serialize.format.php', Type::mixed(), [], true]; + yield ['c13f5526678495e20da82e0a7c1c300b.serialize.format.php', Type::class(ClassicDummy::class), [], false]; + yield [ + 'c13f5526678495e20da82e0a7c1c300b.aa043a938b34c9e6dbe35f74e6b11dd2.serialize.format.php', + Type::class(ClassicDummy::class), + [new GroupTemplateVariation('foo')], + true, + ]; + yield [ + 'c13f5526678495e20da82e0a7c1c300b.357ebc0d58122a5e2949ecd9dc04c02b.serialize.format.php', + Type::class(ClassicDummy::class), + [new GroupTemplateVariation('foo'), new GroupTemplateVariation('bar')], + true, + ]; + } + + /** + * @dataProvider templateContentDataProvider + */ + public function testTemplateContent(string $fixture, Type $type) + { + $typeExtractor = new PhpstanTypeExtractor(new ReflectionTypeExtractor()); + $propertyMetadataLoader = new TypePropertyMetadataLoader( + new AttributePropertyMetadataLoader(new PropertyMetadataLoader($typeExtractor), $typeExtractor), + $typeExtractor, + ); + + $template = new Template( + $this->createStub(TemplateVariationExtractorInterface::class), + new DataModelBuilder($propertyMetadataLoader, $this->createStub(ContainerInterface::class)), + [ + 'normalizer' => new NormalizerEncoderTemplateGenerator('ENCODER'), + 'json' => new JsonTemplateGenerator(), + ], + $this->cacheDir, + ); + + $this->assertStringEqualsFile( + sprintf('%s/Fixtures/templates/serialize/normalizer_%s.php', \dirname(__DIR__, 2), $fixture), + $template->content($type, 'normalizer', new SerializeConfig()), + ); + + $this->assertStringEqualsFile( + sprintf('%s/Fixtures/templates/serialize/json_%s.php', \dirname(__DIR__, 2), $fixture), + $template->content($type, 'json', new SerializeConfig()), + ); + } + + /** + * @return iterable + */ + public static function templateContentDataProvider(): iterable + { + yield ['scalar', Type::int()]; + yield ['nullable_scalar', Type::string(nullable: true)]; + yield ['mixed', Type::mixed()]; + yield ['backed_enum', Type::enum(DummyBackedEnum::class)]; + + yield ['list', Type::list()]; + yield ['nullable_list', Type::list(nullable: true)]; + yield ['iterable_list', Type::iterableList()]; + yield ['dict', Type::dict()]; + yield ['nullable_dict', Type::dict(nullable: true)]; + yield ['iterable_dict', Type::iterableDict()]; + + yield ['object', Type::class(ClassicDummy::class)]; + yield ['nullable_object', Type::class(ClassicDummy::class, nullable: true)]; + } + + public function testThrowOnUnsupportedFormat() + { + $this->expectException(UnsupportedFormatException::class); + + $template = new Template( + $this->createStub(TemplateVariationExtractorInterface::class), + $this->createStub(DataModelBuilderInterface::class), + [], + $this->cacheDir, + false, + ); + $template->content(Type::int(), 'format', new SerializeConfig()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Serialize/VariableNameScoperTest.php b/src/Symfony/Component/Serializer/Tests/Serialize/VariableNameScoperTest.php new file mode 100644 index 0000000000000..ab936ea1e2663 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Serialize/VariableNameScoperTest.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Serialize; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Serialize\VariableNameScoperTrait; + +class VariableNameScoperTest extends TestCase +{ + public function testScopeVariableName() + { + $templateGenerator = new class() { + use VariableNameScoperTrait { + scopeVariableName as private doScopeVariableName; + } + + public function scopeVariableName(string $prefix, array &$context): string + { + return $this->doScopeVariableName($prefix, $context); + } + }; + + $context = []; + + $this->assertSame('foo_0', $templateGenerator->scopeVariableName('foo', $context)); + $this->assertSame('foo_1', $templateGenerator->scopeVariableName('foo', $context)); + $this->assertSame(['variable_counters' => ['foo' => 2]], $context); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/SerializerTest.php b/src/Symfony/Component/Serializer/Tests/SerializerTest.php index 1b80e3b004d3c..78a7fe7c2b1ab 100644 --- a/src/Symfony/Component/Serializer/Tests/SerializerTest.php +++ b/src/Symfony/Component/Serializer/Tests/SerializerTest.php @@ -55,11 +55,15 @@ use Symfony\Component\Serializer\Tests\Fixtures\DummyFirstChildQuux; use Symfony\Component\Serializer\Tests\Fixtures\DummyMessageInterface; use Symfony\Component\Serializer\Tests\Fixtures\DummyMessageNumberOne; +use Symfony\Component\Serializer\Tests\Fixtures\DummyMessageNumberThree; use Symfony\Component\Serializer\Tests\Fixtures\DummyMessageNumberTwo; use Symfony\Component\Serializer\Tests\Fixtures\DummyObjectWithEnumConstructor; use Symfony\Component\Serializer\Tests\Fixtures\DummyObjectWithEnumProperty; use Symfony\Component\Serializer\Tests\Fixtures\FalseBuiltInDummy; +use Symfony\Component\Serializer\Tests\Fixtures\FooImplementationDummy; +use Symfony\Component\Serializer\Tests\Fixtures\FooInterfaceDummyDenormalizer; use Symfony\Component\Serializer\Tests\Fixtures\NormalizableTraversableDummy; +use Symfony\Component\Serializer\Tests\Fixtures\ObjectCollectionPropertyDummy; use Symfony\Component\Serializer\Tests\Fixtures\Php74Full; use Symfony\Component\Serializer\Tests\Fixtures\Php80WithPromotedTypedConstructor; use Symfony\Component\Serializer\Tests\Fixtures\TraversableDummy; @@ -481,6 +485,18 @@ public function testDeserializeAndSerializeNestedInterfacedObjectsWithTheClassMe $this->assertEquals($example, $deserialized); } + public function testDeserializeAndSerializeNestedAbstractAndInterfacedObjectsWithTheClassMetadataDiscriminator() + { + $example = new DummyMessageNumberThree(); + + $serializer = $this->serializerWithClassDiscriminator(); + + $serialized = $serializer->serialize($example, 'json'); + $deserialized = $serializer->deserialize($serialized, DummyMessageInterface::class, 'json'); + + $this->assertEquals($example, $deserialized); + } + public function testExceptionWhenTypeIsNotKnownInDiscriminator() { try { @@ -710,6 +726,21 @@ public function testDeserializeInconsistentScalarArray() $serializer->deserialize('["42"]', 'int[]', 'json'); } + public function testDeserializeOnObjectWithObjectCollectionProperty() + { + $serializer = new Serializer([new FooInterfaceDummyDenormalizer(), new ObjectNormalizer(null, null, null, new PhpDocExtractor())], [new JsonEncoder()]); + + $obj = $serializer->deserialize('{"foo":[{"name":"bar"}]}', ObjectCollectionPropertyDummy::class, 'json'); + $this->assertInstanceOf(ObjectCollectionPropertyDummy::class, $obj); + + $fooDummyObjects = $obj->getFoo(); + $this->assertCount(1, $fooDummyObjects); + + $fooDummyObject = $fooDummyObjects[0]; + $this->assertInstanceOf(FooImplementationDummy::class, $fooDummyObject); + $this->assertSame('bar', $fooDummyObject->name); + } + public function testDeserializeWrappedScalar() { $serializer = new Serializer([new UnwrappingDenormalizer()], ['json' => new JsonEncoder()]); @@ -1440,12 +1471,12 @@ public function __construct($value) class DummyUnionType { /** - * @var \DateTime|bool|null + * @var \DateTimeImmutable|bool|null */ public $changed = false; /** - * @param \DateTime|bool|null + * @param \DateTimeImmutable|bool|null * * @return $this */ diff --git a/src/Symfony/Component/Serializer/Tests/Stream/StreamTest.php b/src/Symfony/Component/Serializer/Tests/Stream/StreamTest.php new file mode 100644 index 0000000000000..370b31f6c00fd --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Stream/StreamTest.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Stream; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Stream\Stream; + +class StreamTest extends TestCase +{ + public function testCreateStream() + { + $stream = new class() extends Stream { + public function __construct() + { + parent::__construct('php://memory', 'w+b'); + } + }; + + $streamMetadata = stream_get_meta_data($stream->resource()); + + $this->assertSame('php://memory', $streamMetadata['uri']); + $this->assertSame('w+b', $streamMetadata['mode']); + } + + public function testCreateWithString() + { + $stream = new class('content') extends Stream { + public function __construct(string $content) + { + parent::__construct('php://memory', 'w+b', $content); + } + }; + + $this->assertSame('content', stream_get_contents($stream->resource())); + } + + public function testToString() + { + $stream = new class() extends Stream { + public function __construct() + { + parent::__construct('php://memory', 'w+b'); + } + }; + + fwrite($stream->resource(), 'content'); + + $this->assertSame('content', (string) $stream); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Template/GroupTemplateVariationTest.php b/src/Symfony/Component/Serializer/Tests/Template/GroupTemplateVariationTest.php new file mode 100644 index 0000000000000..9ee0b7eed99b8 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Template/GroupTemplateVariationTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Template; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Template\GroupTemplateVariation; + +class GroupTemplateVariationTest extends TestCase +{ + public function testConfigure() + { + $serializeConfig = new SerializeConfig(); + $deserializeConfig = new DeserializeConfig(); + + $groupOne = new GroupTemplateVariation('groupOne'); + $groupTwo = new GroupTemplateVariation('groupTwo'); + + $serializeConfig = $groupOne->configure($serializeConfig); + $deserializeConfig = $groupOne->configure($deserializeConfig); + + $this->assertSame(['groupOne'], $serializeConfig->groups()); + $this->assertSame(['groupOne'], $deserializeConfig->groups()); + + $serializeConfig = $groupTwo->configure($serializeConfig); + $deserializeConfig = $groupTwo->configure($deserializeConfig); + + $this->assertSame(['groupOne', 'groupTwo'], $serializeConfig->groups()); + $this->assertSame(['groupOne', 'groupTwo'], $deserializeConfig->groups()); + + $serializeConfig = $groupOne->configure($serializeConfig); + $deserializeConfig = $groupOne->configure($deserializeConfig); + + $this->assertSame(['groupOne', 'groupTwo'], $serializeConfig->groups()); + $this->assertSame(['groupOne', 'groupTwo'], $deserializeConfig->groups()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Template/TemplateVariantTest.php b/src/Symfony/Component/Serializer/Tests/Template/TemplateVariantTest.php new file mode 100644 index 0000000000000..55bc39879c285 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Template/TemplateVariantTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Template; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Template\GroupTemplateVariation; +use Symfony\Component\Serializer\Template\TemplateVariant; +use Symfony\Component\Serializer\Template\TemplateVariation; + +class TemplateVariantTest extends TestCase +{ + public function testCreateSortVariations() + { + $variant = new TemplateVariant(new SerializeConfig(), [ + new GroupTemplateVariation('a'), + new GroupTemplateVariation('c'), + new GroupTemplateVariation('b'), + ]); + + $this->assertSame(['a', 'b', 'c'], array_map(fn (TemplateVariation $v) => $v->value, $variant->variations)); + } + + public function testCreateConfigureConfig() + { + $variant = new TemplateVariant(new SerializeConfig(), [ + new GroupTemplateVariation('a'), + new GroupTemplateVariation('c'), + new GroupTemplateVariation('b'), + ]); + + $this->assertSame(['a', 'b', 'c'], $variant->config->groups()); + + $variant = new TemplateVariant(new DeserializeConfig(), [ + new GroupTemplateVariation('a'), + new GroupTemplateVariation('c'), + new GroupTemplateVariation('b'), + ]); + + $this->assertSame(['a', 'b', 'c'], $variant->config->groups()); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Template/TemplateVariationExtractorTest.php b/src/Symfony/Component/Serializer/Tests/Template/TemplateVariationExtractorTest.php new file mode 100644 index 0000000000000..18e1112c5adfe --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Template/TemplateVariationExtractorTest.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Template; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Template\GroupTemplateVariation; +use Symfony\Component\Serializer\Template\TemplateVariationExtractor; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithGroups; +use Symfony\Component\Serializer\Type\Type; + +class TemplateVariationExtractorTest extends TestCase +{ + public function testExtractFromType() + { + $extractor = new TemplateVariationExtractor(); + + $this->assertEquals([], $extractor->extractVariationsFromType(Type::int())); + $this->assertEquals([], $extractor->extractVariationsFromType(Type::class(ClassicDummy::class))); + + $this->assertEquals([ + new GroupTemplateVariation('one'), + new GroupTemplateVariation('two'), + new GroupTemplateVariation('three'), + ], $extractor->extractVariationsFromType(Type::class(DummyWithGroups::class))); + + $this->assertEquals([ + new GroupTemplateVariation('one'), + new GroupTemplateVariation('two'), + new GroupTemplateVariation('three'), + ], $extractor->extractVariationsFromType(Type::list(Type::class(DummyWithGroups::class)))); + + $this->assertEquals([ + new GroupTemplateVariation('one'), + new GroupTemplateVariation('two'), + new GroupTemplateVariation('three'), + ], $extractor->extractVariationsFromType(Type::union(Type::int(), Type::class(DummyWithGroups::class)))); + + $this->assertEquals([ + new GroupTemplateVariation('one'), + new GroupTemplateVariation('two'), + new GroupTemplateVariation('three'), + ], $extractor->extractVariationsFromType(Type::intersection(Type::int(), Type::class(DummyWithGroups::class)))); + } + + public function testExtractFromConfig() + { + $extractor = new TemplateVariationExtractor(); + + $serializeConfig = (new SerializeConfig())->withGroups(['a', 'b', 'c']); + $deserializeConfig = (new DeserializeConfig())->withGroups(['a', 'b', 'c']); + + $this->assertEquals([ + new GroupTemplateVariation('a'), + new GroupTemplateVariation('b'), + new GroupTemplateVariation('c'), + ], $extractor->extractVariationsFromConfig($serializeConfig)); + + $this->assertEquals([ + new GroupTemplateVariation('a'), + new GroupTemplateVariation('b'), + new GroupTemplateVariation('c'), + ], $extractor->extractVariationsFromConfig($deserializeConfig)); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Template/TemplateVariationTest.php b/src/Symfony/Component/Serializer/Tests/Template/TemplateVariationTest.php new file mode 100644 index 0000000000000..da1f07a544377 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Template/TemplateVariationTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Template; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Deserialize\Config\DeserializeConfig; +use Symfony\Component\Serializer\Serialize\Config\SerializeConfig; +use Symfony\Component\Serializer\Template\TemplateVariation; + +class TemplateVariationTest extends TestCase +{ + public function testCompare() + { + $aa = new TestTemplateVariation(type: 'a', value: 'a'); + $ab = new TestTemplateVariation(type: 'a', value: 'b'); + $ba = new TestTemplateVariation(type: 'b', value: 'a'); + + $this->assertSame(0, $aa->compare($aa)); + $this->assertSame(-1, $aa->compare($ab)); + $this->assertSame(-1, $aa->compare($ba)); + $this->assertSame(-1, $ab->compare($ba)); + $this->assertSame(1, $ba->compare($ab)); + } + + public function testToString() + { + $this->assertSame('type-value', (string) (new TestTemplateVariation(type: 'type', value: 'value'))); + } +} + +readonly class TestTemplateVariation extends TemplateVariation +{ + public function __construct(string $type, string $value) + { + parent::__construct($type, $value); + } + + public function configure(SerializeConfig|DeserializeConfig $config): SerializeConfig + { + return $config; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Type/PhpstanTypeExtractorTest.php b/src/Symfony/Component/Serializer/Tests/Type/PhpstanTypeExtractorTest.php new file mode 100644 index 0000000000000..9e37ecb0fb589 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Type/PhpstanTypeExtractorTest.php @@ -0,0 +1,171 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Type; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\AbstractDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\NonUniqueTemplatePhpstanExtractableDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\PhpstanExtractableDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Enum\DummyBackedEnum; +use Symfony\Component\Serializer\Type\PhpstanTypeExtractor; +use Symfony\Component\Serializer\Type\Type; +use Symfony\Component\Serializer\Type\TypeExtractorInterface; + +class PhpstanTypeExtractorTest extends TestCase +{ + /** + * @dataProvider typesDataProvider + */ + public function testExtractFromProperty(Type $expectedType, string $property) + { + $fallbackExtractor = $this->createStub(TypeExtractorInterface::class); + $fallbackExtractor->method('extractTypeFromProperty')->willReturn(Type::fromString('FALLBACK')); + + $extractor = new PhpstanTypeExtractor($fallbackExtractor); + $reflectionProperty = (new \ReflectionClass(PhpstanExtractableDummy::class))->getProperty($property); + + $this->assertEquals($expectedType, $extractor->extractTypeFromProperty($reflectionProperty)); + } + + /** + * @dataProvider typesDataProvider + */ + public function testExtractFromFunctionReturn(Type $expectedType, string $method) + { + $fallbackExtractor = $this->createStub(TypeExtractorInterface::class); + $fallbackExtractor->method('extractTypeFromFunctionReturn')->willReturn(Type::fromString('FALLBACK')); + + $extractor = new PhpstanTypeExtractor($fallbackExtractor); + $reflectionMethod = (new \ReflectionClass(PhpstanExtractableDummy::class))->getMethod($method); + + $this->assertEquals($expectedType, $extractor->extractTypeFromFunctionReturn($reflectionMethod)); + } + + public function testFallbackOnVoidAndNeverFunctionReturn() + { + $fallbackExtractor = $this->createStub(TypeExtractorInterface::class); + $fallbackExtractor->method('extractTypeFromFunctionReturn')->willReturn(Type::fromString('FALLBACK')); + + $extractor = new PhpstanTypeExtractor($fallbackExtractor); + + $voidReflectionMethod = (new \ReflectionClass(PhpstanExtractableDummy::class))->getMethod('void'); + $neverReflectionMethod = (new \ReflectionClass(PhpstanExtractableDummy::class))->getMethod('never'); + + $this->assertEquals(Type::fromString('FALLBACK'), $extractor->extractTypeFromFunctionReturn($voidReflectionMethod)); + $this->assertEquals(Type::fromString('FALLBACK'), $extractor->extractTypeFromFunctionReturn($neverReflectionMethod)); + } + + public function testExtractClassTypeFromFunctionFunctionReturn() + { + $fallbackExtractor = $this->createStub(TypeExtractorInterface::class); + $fallbackExtractor->method('extractTypeFromFunctionReturn')->willReturn(Type::fromString('FALLBACK')); + + $extractor = new PhpstanTypeExtractor($fallbackExtractor); + + /** @return self */ + $selfReflectionFunction = new \ReflectionFunction(function () { + return $this; + }); + + $this->assertEquals(Type::class(self::class), $extractor->extractTypeFromFunctionReturn($selfReflectionFunction)); + } + + /** + * @dataProvider typesDataProvider + */ + public function testExtractFromParameter(Type $expectedType, string $method) + { + $fallbackExtractor = $this->createStub(TypeExtractorInterface::class); + $fallbackExtractor->method('extractTypeFromParameter')->willReturn(Type::fromString('FALLBACK')); + + $extractor = new PhpstanTypeExtractor($fallbackExtractor); + + $reflectionParameter = (new \ReflectionClass(PhpstanExtractableDummy::class))->getMethod($method)->getParameters()[0]; + + $this->assertEquals($expectedType, $extractor->extractTypeFromParameter($reflectionParameter)); + } + + public function testExtractClassTypeFromParameter() + { + $fallbackExtractor = $this->createStub(TypeExtractorInterface::class); + $fallbackExtractor->method('extractTypeFromParameter')->willReturn(Type::fromString('FALLBACK')); + + $extractor = new PhpstanTypeExtractor($fallbackExtractor); + + /** @param self $_ */ + $selfReflectionFunction = new \ReflectionFunction(function ($_) { + }); + + $this->assertEquals(Type::class(self::class), $extractor->extractTypeFromParameter($selfReflectionFunction->getParameters()[0])); + } + + /** + * @return iterable + */ + public static function typesDataProvider(): iterable + { + yield [Type::mixed(), 'mixed']; + yield [Type::bool(), 'bool']; + yield [Type::bool(), 'boolean']; + yield [Type::bool(), 'true']; + yield [Type::bool(), 'false']; + yield [Type::int(), 'int']; + yield [Type::int(), 'integer']; + yield [Type::float(), 'float']; + yield [Type::string(), 'string']; + yield [Type::resource(), 'resource']; + yield [Type::object(), 'object']; + yield [Type::callable(), 'callable']; + yield [Type::array(), 'array']; + yield [Type::list(), 'list']; + yield [Type::array(), 'nonEmptyArray']; + yield [Type::list(), 'nonEmptyList']; + yield [Type::iterable(), 'iterable']; + yield [Type::null(), 'null']; + yield [Type::class(PhpstanExtractableDummy::class), 'self']; + yield [Type::class(PhpstanExtractableDummy::class), 'static']; + yield [Type::class(AbstractDummy::class), 'parent']; + yield [Type::fromString('Symfony\\Component\\Serializer\\Tests\\Fixtures\\Dto\\scoped'), 'scoped']; + yield [Type::enum(DummyBackedEnum::class), 'use']; + yield [Type::class(ClassicDummy::class), 'sameNamespace']; + yield [Type::int(nullable: true), 'nullable']; + yield [Type::union(Type::int(), Type::string()), 'union']; + yield [Type::intersection(Type::int(), Type::string()), 'intersection']; + yield [Type::list(Type::string()), 'genericList']; + yield [Type::list(Type::string()), 'genericArrayList']; + yield [Type::dict(Type::string()), 'genericDict']; + yield [Type::list(Type::string()), 'squareBracketList']; + yield [Type::dict(Type::union(Type::int(), Type::string())), 'bracketList']; + yield [Type::dict(), 'emptyBracketList']; + yield [Type::class(\ArrayIterator::class, genericParameterTypes: [Type::fromString('Tk'), Type::fromString('Tv')]), 'generic']; + yield [Type::fromString('Tv'), 'genericParameter']; + yield [Type::fromString('FALLBACK'), 'undefined']; + } + + public function testExtractTemplateFromClass() + { + $extractor = new PhpstanTypeExtractor($this->createStub(TypeExtractorInterface::class)); + + $this->assertSame(['Tk', 'Tv'], $extractor->extractTemplateFromClass(new \ReflectionClass(PhpstanExtractableDummy::class))); + } + + public function testExtractTemplateFromClassThrowWhenNonUniqueTemplate() + { + $extractor = new PhpstanTypeExtractor($this->createStub(TypeExtractorInterface::class)); + + $this->expectException(UnexpectedValueException::class); + + $extractor->extractTemplateFromClass(new \ReflectionClass(NonUniqueTemplatePhpstanExtractableDummy::class)); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Type/ReflectionTypeExtractorTest.php b/src/Symfony/Component/Serializer/Tests/Type/ReflectionTypeExtractorTest.php new file mode 100644 index 0000000000000..2161b8ed027ec --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Type/ReflectionTypeExtractorTest.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Type; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\UnsupportedException; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\AbstractDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ReflectionExtractableDummy; +use Symfony\Component\Serializer\Type\ReflectionTypeExtractor; +use Symfony\Component\Serializer\Type\Type; + +class ReflectionTypeExtractorTest extends TestCase +{ + /** + * @dataProvider typesDataProvider + */ + public function testExtractFromProperty(Type $expectedType, string $property) + { + $reflectionProperty = (new \ReflectionClass(ReflectionExtractableDummy::class))->getProperty($property); + + $this->assertEquals($expectedType, (new ReflectionTypeExtractor())->extractTypeFromProperty($reflectionProperty)); + } + + public function testThrowIfCannotFindPropertyType() + { + $reflectionProperty = (new \ReflectionClass(ReflectionExtractableDummy::class))->getProperty('undefined'); + + $this->expectException(InvalidArgumentException::class); + + (new ReflectionTypeExtractor())->extractTypeFromProperty($reflectionProperty); + } + + /** + * @dataProvider typesDataProvider + */ + public function testExtractFromFunctionReturn(Type $expectedType, string $method) + { + $reflectionMethod = (new \ReflectionClass(ReflectionExtractableDummy::class))->getMethod($method); + + $this->assertEquals($expectedType, (new ReflectionTypeExtractor())->extractTypeFromFunctionReturn($reflectionMethod)); + } + + public function testExtractClassTypeFromFunctionReturnType() + { + $selfReflectionFunction = new \ReflectionFunction(function (): self { + return $this; + }); + + $this->assertEquals(Type::class(self::class), (new ReflectionTypeExtractor())->extractTypeFromFunctionReturn($selfReflectionFunction)); + + $parentReflectionFunction = new \ReflectionFunction(function (): parent { + return $this; + }); + + $this->assertEquals(Type::class(parent::class), (new ReflectionTypeExtractor())->extractTypeFromFunctionReturn($parentReflectionFunction)); + } + + public function testCannotHandleVoidReturnType() + { + $reflectionMethod = (new \ReflectionClass(ReflectionExtractableDummy::class))->getMethod('void'); + + $this->expectException(UnsupportedException::class); + + (new ReflectionTypeExtractor())->extractTypeFromFunctionReturn($reflectionMethod); + } + + public function testCannotHandleNeverReturnType() + { + $reflectionMethod = (new \ReflectionClass(ReflectionExtractableDummy::class))->getMethod('never'); + + $this->expectException(UnsupportedException::class); + + (new ReflectionTypeExtractor())->extractTypeFromFunctionReturn($reflectionMethod); + } + + public function testThrowIfCannotFindReturnType() + { + $this->expectException(InvalidArgumentException::class); + + $reflectionMethod = (new \ReflectionClass(ReflectionExtractableDummy::class))->getMethod('undefined'); + + (new ReflectionTypeExtractor())->extractTypeFromFunctionReturn($reflectionMethod); + } + + /** + * @dataProvider typesDataProvider + */ + public function testExtractFromParameter(Type $expectedType, string $method) + { + $reflectionParameter = (new \ReflectionClass(ReflectionExtractableDummy::class))->getMethod($method)->getParameters()[0]; + + $this->assertEquals($expectedType, (new ReflectionTypeExtractor())->extractTypeFromParameter($reflectionParameter)); + } + + public function testThrowIfCannotFindParameterType() + { + $this->expectException(InvalidArgumentException::class); + + $reflectionParameter = (new \ReflectionClass(ReflectionExtractableDummy::class))->getMethod('undefined')->getParameters()[0]; + + (new ReflectionTypeExtractor())->extractTypeFromParameter($reflectionParameter); + } + + /** + * @return iterable + */ + public static function typesDataProvider(): iterable + { + yield [Type::mixed(), 'mixed']; + yield [Type::int(), 'int']; + yield [Type::string(), 'string']; + yield [Type::float(), 'float']; + yield [Type::bool(), 'bool']; + yield [Type::array(), 'array']; + yield [Type::class(ReflectionExtractableDummy::class), 'self']; + yield [Type::class(AbstractDummy::class), 'parent']; + yield [Type::class(ClassicDummy::class), 'class']; + yield [Type::union(Type::string(), Type::int()), 'union']; + yield [Type::int(nullable: true), 'nullableBuiltin']; + yield [Type::class(ClassicDummy::class, nullable: true), 'nullableClass']; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Type/TypeGenericsHelperTest.php b/src/Symfony/Component/Serializer/Tests/Type/TypeGenericsHelperTest.php new file mode 100644 index 0000000000000..ef8b22d7b2050 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Type/TypeGenericsHelperTest.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Type; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithGenerics; +use Symfony\Component\Serializer\Type\PhpstanTypeExtractor; +use Symfony\Component\Serializer\Type\ReflectionTypeExtractor; +use Symfony\Component\Serializer\Type\Type; +use Symfony\Component\Serializer\Type\TypeExtractorInterface; +use Symfony\Component\Serializer\Type\TypeGenericsHelper; + +class TypeGenericsHelperTest extends TestCase +{ + /** + * @dataProvider classGenericTypesDataProvider + * + * @param array $expectedGenericTypes + * @param class-string $className + */ + public function testClassGenericTypes(array $expectedGenericTypes, string $className, Type $type) + { + $typeGenericsHelper = new TypeGenericsHelper(new PhpstanTypeExtractor(new ReflectionTypeExtractor())); + + $this->assertEquals($expectedGenericTypes, $typeGenericsHelper->classGenericTypes($className, $type)); + } + + /** + * @return iterable, 1: class-string, 2: Type}> + */ + public static function classGenericTypesDataProvider(): iterable + { + yield [[], ClassicDummy::class, Type::class(ClassicDummy::class)]; + yield [[], ClassicDummy::class, Type::class(DummyWithGenerics::class, genericParameterTypes: [Type::int()])]; + yield [['T' => Type::int()], DummyWithGenerics::class, Type::class(DummyWithGenerics::class, genericParameterTypes: [Type::int()])]; + yield [['T' => Type::int()], DummyWithGenerics::class, Type::list(Type::class(DummyWithGenerics::class, genericParameterTypes: [Type::int()]))]; + yield [['T' => Type::int()], DummyWithGenerics::class, Type::union(Type::int(), Type::class(DummyWithGenerics::class, genericParameterTypes: [Type::int()]))]; + yield [['T' => Type::int()], DummyWithGenerics::class, Type::intersection(Type::int(), Type::class(DummyWithGenerics::class, genericParameterTypes: [Type::int()]))]; + } + + /** + * @dataProvider replaceGenericTypesDataProvider + * + * @param array $genericTypes + */ + public function testReplaceGenericTypes(Type $expectedType, Type $type, array $genericTypes) + { + $this->assertEquals($expectedType, (new TypeGenericsHelper($this->createStub(TypeExtractorInterface::class)))->replaceGenericTypes($type, $genericTypes)); + } + + public function testThrowWhenGenericParametersDoesNotMatchTemplate() + { + $this->expectException(InvalidArgumentException::class); + + $typeGenericsHelper = new TypeGenericsHelper(new PhpstanTypeExtractor(new ReflectionTypeExtractor())); + $typeGenericsHelper->classGenericTypes(DummyWithGenerics::class, Type::class(DummyWithGenerics::class)); + } + + /** + * @return iterable}> + */ + public static function replaceGenericTypesDataProvider(): iterable + { + yield [Type::fromString('T'), Type::fromString('T'), []]; + yield [Type::fromString('Foo'), Type::fromString('T'), ['T' => Type::fromString('Foo')]]; + + yield [Type::list(Type::fromString('Foo')), Type::list(Type::fromString('T')), ['T' => Type::fromString('Foo')]]; + yield [Type::array(Type::fromString('Foo'), Type::fromString('Foo')), Type::array(Type::fromString('T'), Type::fromString('T')), ['T' => Type::fromString('Foo')]]; + + yield [Type::union(Type::int(), Type::fromString('Foo')), Type::union(Type::int(), Type::fromString('T')), ['T' => Type::fromString('Foo')]]; + yield [ + Type::union(Type::int(), Type::fromString('Foo'), Type::fromString('Bar')), + Type::union(Type::int(), Type::fromString('T'), Type::fromString('U')), + ['T' => Type::fromString('Foo'), 'U' => Type::fromString('Bar')], + ]; + yield [ + Type::union(Type::int(), Type::fromString('Foo'), Type::dict(Type::fromString('Bar'))), + Type::union(Type::int(), Type::fromString('T'), Type::dict(Type::fromString('U'))), + ['T' => Type::fromString('Foo'), 'U' => Type::fromString('Bar')], + ]; + + yield [Type::intersection(Type::int(), Type::fromString('Foo')), Type::intersection(Type::int(), Type::fromString('T')), ['T' => Type::fromString('Foo')]]; + yield [ + Type::intersection(Type::int(), Type::fromString('Foo'), Type::fromString('Bar')), + Type::intersection(Type::int(), Type::fromString('T'), Type::fromString('U')), + ['T' => Type::fromString('Foo'), 'U' => Type::fromString('Bar')], + ]; + yield [ + Type::intersection(Type::int(), Type::fromString('Foo'), Type::dict(Type::fromString('Bar'))), + Type::intersection(Type::int(), Type::fromString('T'), Type::dict(Type::fromString('U'))), + ['T' => Type::fromString('Foo'), 'U' => Type::fromString('Bar')], + ]; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Type/TypeTest.php b/src/Symfony/Component/Serializer/Tests/Type/TypeTest.php new file mode 100644 index 0000000000000..79be2d73e328c --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Type/TypeTest.php @@ -0,0 +1,473 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Type; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\ClassicDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dto\DummyWithGenerics; +use Symfony\Component\Serializer\Tests\Fixtures\Enum\DummyBackedEnum; +use Symfony\Component\Serializer\Tests\Fixtures\Enum\DummyUnitEnum; +use Symfony\Component\Serializer\Type\Type; + +class TypeTest extends TestCase +{ + /** + * @dataProvider toStringDataProvider + */ + public function testToString(string $expectedString, Type $type) + { + $this->assertSame($expectedString, (string) $type); + } + + /** + * @return iterable + */ + public static function toStringDataProvider(): iterable + { + yield ['null', Type::null()]; + yield ['int', Type::int()]; + yield ['string', Type::string()]; + yield ['float', Type::float()]; + yield ['bool', Type::bool()]; + yield ['?int', Type::int(nullable: true)]; + yield ['object', Type::object()]; + + yield [ClassicDummy::class, Type::class(ClassicDummy::class)]; + yield ['?'.ClassicDummy::class, Type::class(ClassicDummy::class, nullable: true)]; + + yield [ClassicDummy::class.'', Type::class(ClassicDummy::class, genericParameterTypes: [Type::int()])]; + yield [ + ClassicDummy::class.'<'.ClassicDummy::class.'>', + Type::class( + ClassicDummy::class, + genericParameterTypes: [Type::class(ClassicDummy::class, genericParameterTypes: [Type::bool(nullable: true)])], + ), + ]; + + yield ['array', Type::array()]; + yield ['array', Type::list(Type::int())]; + yield ['array>', Type::dict(Type::list(Type::bool(), nullable: true))]; + + yield ['int|string', Type::union(Type::int(), Type::string())]; + yield ['int|array|null', Type::union(Type::int(), Type::list(Type::int()), Type::null())]; + + yield ['int&string', Type::intersection(Type::int(), Type::string())]; + yield ['int&array&null', Type::intersection(Type::int(), Type::list(Type::int()), Type::null())]; + } + + public function testGetCollectionKeyType() + { + $this->assertEquals(Type::string(), Type::dict()->collectionKeyType()); + $this->assertEquals(Type::union(Type::int(), Type::string()), Type::array()->collectionKeyType()); + } + + public function testGetCollectionValueType() + { + $this->assertEquals(Type::int(), Type::list(Type::int())->collectionValueType()); + $this->assertEquals(Type::mixed(), Type::array()->collectionValueType()); + } + + /** + * @dataProvider isserDataProvider + */ + public function testIsser( + Type $type, + bool $scalar, + bool $null, + bool $nullable, + bool $object, + bool $enum, + bool $collection, + bool $list, + bool $dict, + bool $generic, + bool $class, + bool $union, + bool $intersection, + ) { + $this->assertSame($scalar, $type->isScalar()); + $this->assertSame($null, $type->isNull()); + $this->assertSame($nullable, $type->isNullable()); + $this->assertSame($object, $type->isObject()); + $this->assertSame($enum, $type->isEnum()); + $this->assertSame($collection, $type->isCollection()); + $this->assertSame($list, $type->isList()); + $this->assertSame($dict, $type->isDict()); + $this->assertSame($generic, $type->isGeneric()); + $this->assertSame($class, $type->hasClass()); + $this->assertSame($union, $type->isUnion()); + $this->assertSame($intersection, $type->isIntersection()); + } + + /** + * @return iterable> + */ + public static function isserDataProvider(): iterable + { + yield [ + 'type' => Type::int(), + 'scalar' => true, + 'null' => false, + 'nullable' => false, + 'object' => false, + 'enum' => false, + 'collection' => false, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => false, + 'union' => false, + 'intersection' => false, + ]; + yield [ + 'type' => Type::string(), + 'scalar' => true, + 'null' => false, + 'nullable' => false, + 'object' => false, + 'enum' => false, + 'collection' => false, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => false, + 'union' => false, + 'intersection' => false, + ]; + yield [ + 'type' => Type::bool(), + 'scalar' => true, + 'null' => false, + 'nullable' => false, + 'object' => false, + 'enum' => false, + 'collection' => false, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => false, + 'union' => false, + 'intersection' => false, + ]; + yield [ + 'type' => Type::float(), + 'scalar' => true, + 'null' => false, + 'nullable' => false, + 'object' => false, + 'enum' => false, + 'collection' => false, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => false, + 'union' => false, + 'intersection' => false, + ]; + yield [ + 'type' => Type::null(), + 'scalar' => true, + 'null' => true, + 'nullable' => false, + 'object' => false, + 'enum' => false, + 'collection' => false, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => false, + 'union' => false, + 'intersection' => false, + ]; + yield [ + 'type' => Type::array(), + 'scalar' => false, + 'null' => false, + 'nullable' => false, + 'object' => false, + 'enum' => false, + 'collection' => true, + 'list' => false, + 'dict' => false, + 'generic' => true, + 'class' => false, + 'union' => false, + 'intersection' => false, + ]; + yield [ + 'type' => Type::object(), + 'scalar' => false, + 'null' => false, + 'nullable' => false, + 'object' => true, + 'enum' => false, + 'collection' => false, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => false, + 'union' => false, + 'intersection' => false, + ]; + yield [ + 'type' => Type::class(ClassicDummy::class), + 'scalar' => false, + 'null' => false, + 'nullable' => false, + 'object' => true, + 'enum' => false, + 'collection' => false, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => true, + 'union' => false, + 'intersection' => false, + ]; + yield [ + 'type' => Type::enum(DummyBackedEnum::class), + 'scalar' => false, + 'null' => false, + 'nullable' => false, + 'object' => false, + 'enum' => true, + 'collection' => false, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => true, + 'union' => false, + 'intersection' => false, + ]; + yield [ + 'type' => Type::class(ClassicDummy::class, genericParameterTypes: [Type::int()]), + 'scalar' => false, + 'null' => false, + 'nullable' => false, + 'object' => true, + 'enum' => false, + 'collection' => false, + 'list' => false, + 'dict' => false, + 'generic' => true, + 'class' => true, + 'union' => false, + 'intersection' => false, + ]; + yield [ + 'type' => Type::list(Type::int()), + 'scalar' => false, + 'null' => false, + 'nullable' => false, + 'object' => false, + 'enum' => false, + 'collection' => true, + 'list' => true, + 'dict' => false, + 'generic' => true, + 'class' => false, + 'union' => false, + 'intersection' => false, + ]; + yield [ + 'type' => Type::union(Type::int(), Type::float()), + 'scalar' => true, + 'null' => false, + 'nullable' => false, + 'object' => false, + 'enum' => false, + 'collection' => false, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => false, + 'union' => true, + 'intersection' => false, + ]; + yield [ + 'type' => Type::union(Type::int(), Type::float(), Type::null()), + 'scalar' => true, + 'null' => false, + 'nullable' => true, + 'object' => false, + 'enum' => false, + 'collection' => false, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => false, + 'union' => true, + 'intersection' => false, + ]; + yield [ + 'type' => Type::union(Type::int(), Type::array()), + 'scalar' => false, + 'null' => false, + 'nullable' => false, + 'object' => false, + 'enum' => false, + 'collection' => false, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => false, + 'union' => true, + 'intersection' => false, + ]; + yield [ + 'type' => Type::union(Type::iterable(), Type::array()), + 'scalar' => false, + 'null' => false, + 'nullable' => false, + 'object' => false, + 'enum' => false, + 'collection' => true, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => false, + 'union' => true, + 'intersection' => false, + ]; + yield [ + 'type' => Type::intersection(Type::int(), Type::float(), Type::null()), + 'scalar' => true, + 'null' => false, + 'nullable' => false, + 'object' => false, + 'enum' => false, + 'collection' => false, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => false, + 'union' => false, + 'intersection' => true, + ]; + yield [ + 'type' => Type::intersection(Type::int(), Type::array()), + 'scalar' => true, + 'null' => false, + 'nullable' => false, + 'object' => false, + 'enum' => false, + 'collection' => true, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => false, + 'union' => false, + 'intersection' => true, + ]; + yield [ + 'type' => Type::intersection(Type::iterable(), Type::array()), + 'scalar' => false, + 'null' => false, + 'nullable' => false, + 'object' => false, + 'enum' => false, + 'collection' => true, + 'list' => false, + 'dict' => false, + 'generic' => false, + 'class' => false, + 'union' => false, + 'intersection' => true, + ]; + } + + /** + * @dataProvider fromStringDataProvider + */ + public function testFromString(Type $expectedType, string $string) + { + $this->assertEquals($expectedType, Type::fromString($string)); + } + + /** + * @return iterable + */ + public static function fromStringDataProvider(): iterable + { + yield [Type::null(), 'null']; + yield [Type::int(), 'int']; + yield [Type::string(nullable: true), '?string']; + + yield [Type::array(), 'array']; + yield [Type::list(), 'list']; + yield [Type::iterable(), 'iterable']; + yield [Type::list(), 'array']; + yield [Type::iterableList(), 'iterable']; + + yield [Type::class(ClassicDummy::class), ClassicDummy::class]; + yield [Type::enum(DummyBackedEnum::class), DummyBackedEnum::class]; + yield [Type::class(\DateTimeInterface::class), \DateTimeInterface::class]; + + yield [Type::list(Type::dict(Type::bool(nullable: true))), 'list>']; + yield [Type::list(Type::dict(Type::bool(nullable: true))), 'array>']; + yield [Type::class(DummyWithGenerics::class, genericParameterTypes: [Type::int()]), DummyWithGenerics::class.'']; + + yield [Type::union(Type::int(), Type::string()), 'int|string']; + yield [Type::union(Type::int(), Type::string(), Type::null()), 'int|?string']; + + return; + yield [Type::union(Type::int(), Type::dict(Type::bool())), 'int|array']; + + yield [Type::intersection(Type::int(), Type::string()), 'int&string']; + yield [Type::intersection(Type::int(), Type::string(), Type::null()), 'int&?string']; + yield [Type::intersection(Type::int(), Type::dict(Type::bool())), 'int&array']; + } + + public function testFromStringThowOnDNF() + { + $this->expectException(InvalidArgumentException::class); + Type::fromString('int|string&float'); + } + + /** + * @dataProvider fromStringThowOnInvalidStringDataProvider + */ + public function testFromStringThowOnInvalidString(string $string) + { + $this->expectException(InvalidArgumentException::class); + + Type::fromString($string); + } + + /** + * @return iterable + */ + public function fromStringThowOnInvalidStringDataProvider() + { + yield ['int|string&float']; + yield ['array>>']; + yield ['array']; + } + + public function testEnumFactory() + { + $unitEnumType = Type::enum(DummyUnitEnum::class); + + $this->assertSame(DummyUnitEnum::class, $unitEnumType->className()); + $this->assertTrue($unitEnumType->isEnum()); + $this->assertFalse($unitEnumType->isBackedEnum()); + + $backedEnumType = Type::enum(DummyBackedEnum::class); + + $this->assertSame(DummyBackedEnum::class, $backedEnumType->className()); + $this->assertEquals(Type::int(), $backedEnumType->backingType()); + $this->assertTrue($backedEnumType->isEnum()); + $this->assertTrue($backedEnumType->isBackedEnum()); + } +} diff --git a/src/Symfony/Component/Serializer/Type/PhpstanTypeExtractor.php b/src/Symfony/Component/Serializer/Type/PhpstanTypeExtractor.php new file mode 100644 index 0000000000000..ea018eff56e56 --- /dev/null +++ b/src/Symfony/Component/Serializer/Type/PhpstanTypeExtractor.php @@ -0,0 +1,152 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Type; + +use PHPStan\PhpDocParser\Ast\PhpDoc\InvalidTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; +use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use PHPStan\PhpDocParser\Lexer\Lexer; +use PHPStan\PhpDocParser\Parser\ConstExprParser; +use PHPStan\PhpDocParser\Parser\PhpDocParser; +use PHPStan\PhpDocParser\Parser\TokenIterator; +use PHPStan\PhpDocParser\Parser\TypeParser; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; + +/** + * Extracts type from PHPStan documentation. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class PhpstanTypeExtractor implements TypeExtractorInterface, TemplateExtractorInterface +{ + private readonly PhpstanTypeHelper $phpstanTypeHelper; + private readonly PhpDocParser $phpstanDocParser; + private readonly Lexer $phpstanLexer; + + public function __construct( + private readonly TypeExtractorInterface $decoratedTypeExtractor, + ) { + $this->phpstanTypeHelper = new PhpstanTypeHelper(); + $this->phpstanDocParser = new PhpDocParser(new TypeParser(new ConstExprParser()), new ConstExprParser()); + $this->phpstanLexer = new Lexer(); + } + + public function extractTypeFromProperty(\ReflectionProperty $property): Type + { + if (null === $typeNode = $this->getTypeNode($property)) { + return $this->decoratedTypeExtractor->extractTypeFromProperty($property); + } + + return $this->phpstanTypeHelper->getType($typeNode, $property->getDeclaringClass(), $this->getTemplateNodes($property->getDeclaringClass())); + } + + public function extractTypeFromFunctionReturn(\ReflectionFunctionAbstract $function): Type + { + if (null === $typeNode = $this->getTypeNode($function)) { + return $this->decoratedTypeExtractor->extractTypeFromFunctionReturn($function); + } + + $declaringClass = $function instanceof \ReflectionMethod ? $function->getDeclaringClass() : $function->getClosureScopeClass(); + if (null === $declaringClass) { + throw new UnexpectedValueException(sprintf('"%s()" does not have any declaring class.', $function->getName())); + } + + return $this->phpstanTypeHelper->getType($typeNode, $declaringClass, $this->getTemplateNodes($declaringClass)); + } + + public function extractTypeFromParameter(\ReflectionParameter $parameter): Type + { + if (null === $typeNode = $this->getTypeNode($parameter)) { + return $this->decoratedTypeExtractor->extractTypeFromParameter($parameter); + } + + $function = $parameter->getDeclaringFunction(); + $declaringClass = $function instanceof \ReflectionMethod ? $function->getDeclaringClass() : $function->getClosureScopeClass(); + + if (null === $declaringClass) { + throw new UnexpectedValueException(sprintf('"%s()" does not have any declaring class.', $function->getName())); + } + + return $this->phpstanTypeHelper->getType($typeNode, $declaringClass, $this->getTemplateNodes($declaringClass)); + } + + public function extractTemplateFromClass(\ReflectionClass $class): array + { + $templates = array_map(fn (TemplateTagValueNode $t): string => $t->name, $this->getTemplateNodes($class)); + + if (array_unique($templates) !== $templates) { + throw new UnexpectedValueException(sprintf('Templates defined in "%s" must be unique.', $class->getName())); + } + + return $templates; + } + + private function getTypeNode(\ReflectionProperty|\ReflectionFunctionAbstract|\ReflectionParameter $reflection): ?TypeNode + { + $rawDocNode = $reflection instanceof \ReflectionParameter ? $reflection->getDeclaringFunction()->getDocComment() : $reflection->getDocComment(); + if (!$rawDocNode) { + return null; + } + + $tokens = new TokenIterator($this->phpstanLexer->tokenize($rawDocNode)); + $docNode = $this->phpstanDocParser->parse($tokens); + $tokens->consumeTokenType(Lexer::TOKEN_END); + + $tagName = match (true) { + $reflection instanceof \ReflectionProperty => '@var', + $reflection instanceof \ReflectionFunctionAbstract => '@return', + $reflection instanceof \ReflectionParameter => '@param', + }; + + $tags = $docNode->getTagsByName($tagName); + $tag = reset($tags) ?: null; + + /** @var VarTagValueNode|ReturnTagValueNode|ParamTagValueNode|InvalidTagValueNode|null $tagValue */ + $tagValue = $tag?->value; + + if (null === $tagValue || $tagValue instanceof InvalidTagValueNode) { + return null; + } + + return $tagValue->type; + } + + /** + * @param \ReflectionClass $reflection + * + * @return list + */ + private function getTemplateNodes(\ReflectionClass $reflection): array + { + if (null === $rawDocNode = $reflection->getDocComment() ?: null) { + return []; + } + + $tokens = new TokenIterator($this->phpstanLexer->tokenize($rawDocNode)); + $docNode = $this->phpstanDocParser->parse($tokens); + $tokens->consumeTokenType(Lexer::TOKEN_END); + + $tags = $docNode->getTagsByName('@template'); + + return array_values(array_filter( + array_map(fn (PhpDocTagNode $t): PhpDocTagValueNode => $t->value, $tags), + fn (PhpDocTagValueNode $v): bool => $v instanceof TemplateTagValueNode, + )); + } +} diff --git a/src/Symfony/Component/Serializer/Type/PhpstanTypeHelper.php b/src/Symfony/Component/Serializer/Type/PhpstanTypeHelper.php new file mode 100644 index 0000000000000..edd08505c3f93 --- /dev/null +++ b/src/Symfony/Component/Serializer/Type/PhpstanTypeHelper.php @@ -0,0 +1,140 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Type; + +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; +use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode; +use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; +use Symfony\Component\Serializer\Exception\UnsupportedException; + +/** + * @author Mathias Arlaud + * + * @internal + */ +final class PhpstanTypeHelper +{ + /** + * @param \ReflectionClass $declaringClass + * @param list $templateNodes + */ + public function getType(TypeNode $typeNode, \ReflectionClass $declaringClass, array $templateNodes): Type + { + $templateNodeNames = array_map(fn (TemplateTagValueNode $t): string => $t->name, $templateNodes); + + return Type::fromString($this->extractType($typeNode, TypeNameResolver::createForClass($declaringClass, $templateNodeNames))); + } + + private function extractType(TypeNode $node, TypeNameResolver $nameResolver): string + { + if ($node instanceof UnionTypeNode) { + return implode('|', array_map(fn (TypeNode $t): string => $this->extractType($t, $nameResolver), $node->types)); + } + + if ($node instanceof IntersectionTypeNode) { + return implode('&', array_map(fn (TypeNode $t): string => $this->extractType($t, $nameResolver), $node->types)); + } + + if ($node instanceof NullableTypeNode) { + return sprintf('?%s', $this->extractType($node->type, $nameResolver)); + } + + if ($node instanceof IdentifierTypeNode) { + return $this->extractIdentifierType($node, $nameResolver); + } + + if ($node instanceof GenericTypeNode) { + return $this->extractGenericType($node, $nameResolver); + } + + if ($node instanceof ArrayTypeNode || $node instanceof ArrayShapeNode) { + return $this->extractArrayType($node, $nameResolver); + } + + if ($node instanceof ThisTypeNode) { + return $nameResolver->resolveRootClass(); + } + + if ($node instanceof CallableTypeNode) { + return 'callable'; + } + + throw new UnsupportedException(sprintf('"%s" type is not supported.', (string) $node)); + } + + private function extractIdentifierType(IdentifierTypeNode $node, TypeNameResolver $nameResolver): string + { + return match ($node->name) { + 'bool', 'boolean', 'true', 'false' => 'bool', + 'int', 'integer' => 'int', + 'float' => 'float', + 'string' => 'string', + 'resource' => 'resource', + 'object' => 'object', + 'callable' => 'callable', + 'array', 'non-empty-array' => 'array', + 'list', 'non-empty-list' => 'list', + 'iterable' => 'iterable', + 'mixed' => 'mixed', + 'null' => 'null', + 'static', 'self' => $nameResolver->resolveRootClass(), + 'parent' => $nameResolver->resolveParentClass(), + default => $nameResolver->resolve($node->name), + }; + } + + private function extractGenericType(GenericTypeNode $node, TypeNameResolver $nameResolver): string + { + $genericParameters = array_map(fn (TypeNode $t): string => $this->extractType($t, $nameResolver), $node->genericTypes); + + if ('array' === $mainType = $this->extractType($node->type, $nameResolver)) { + $keyType = 'int'; + $valueType = $genericParameters[0]; + if (2 === \count($genericParameters)) { + $keyType = $valueType; + $valueType = $genericParameters[1]; + } + + $genericParameters = [$keyType, $valueType]; + } + + return sprintf('%s<%s>', $mainType, implode(', ', $genericParameters)); + } + + private function extractArrayType(ArrayTypeNode|ArrayShapeNode $node, TypeNameResolver $nameResolver): string + { + if ($node instanceof ArrayTypeNode) { + return sprintf('array', $this->extractType($node->type, $nameResolver)); + } + + if ([] === $items = $node->items) { + return 'array'; + } + + $valueType = $node->items[0]->valueType; + + if (\count($items) > 1) { + $valueType = new UnionTypeNode(array_map(fn (ArrayShapeItemNode $i): TypeNode => $i->valueType, $node->items)); + } + + return sprintf('array', $this->extractType($valueType, $nameResolver)); + } +} diff --git a/src/Symfony/Component/Serializer/Type/ReflectionTypeExtractor.php b/src/Symfony/Component/Serializer/Type/ReflectionTypeExtractor.php new file mode 100644 index 0000000000000..8e479d2d68ab4 --- /dev/null +++ b/src/Symfony/Component/Serializer/Type/ReflectionTypeExtractor.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Type; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\UnsupportedException; + +/** + * Extracts type from PHP reflection. + * + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class ReflectionTypeExtractor implements TypeExtractorInterface +{ + public function extractTypeFromProperty(\ReflectionProperty $property): Type + { + if (null === $type = $property->getType()) { + throw new InvalidArgumentException(sprintf('Type of "%s::$%s" property has not been defined.', $property->getDeclaringClass()->getName(), $property->getName())); + } + + return $this->extractTypeFromReflection($type, $property->getDeclaringClass()); + } + + public function extractTypeFromFunctionReturn(\ReflectionFunctionAbstract $function): Type + { + $declaringClass = $function instanceof \ReflectionMethod ? $function->getDeclaringClass() : $function->getClosureScopeClass(); + + if (null === $type = $function->getReturnType()) { + $path = null !== $declaringClass + ? sprintf('%s::%s()', $declaringClass->getName(), $function->getName()) + : sprintf('%s()', $function->getName()); + + throw new InvalidArgumentException(sprintf('Type of "%s" return value has not been defined.', $path)); + } + + return $this->extractTypeFromReflection($type, $declaringClass); + } + + public function extractTypeFromParameter(\ReflectionParameter $parameter): Type + { + $function = $parameter->getDeclaringFunction(); + + $declaringClass = $function instanceof \ReflectionMethod ? $function->getDeclaringClass() : $function->getClosureScopeClass(); + + if (null === $type = $parameter->getType()) { + $path = null !== $declaringClass + ? sprintf('%s::%s($%s)', $declaringClass->getName(), $function->getName(), $parameter->getName()) + : sprintf('%s($%s)', $function->getName(), $parameter->getName()); + + throw new InvalidArgumentException(sprintf('Type of "%s" parameter has not been defined.', $path)); + } + + return $this->extractTypeFromReflection($type, $declaringClass); + } + + /** + * @param \ReflectionClass|null $declaringClass + */ + private function extractTypeFromReflection(\ReflectionType $reflection, ?\ReflectionClass $declaringClass): Type + { + if (!($reflection instanceof \ReflectionUnionType || $reflection instanceof \ReflectionNamedType || $reflection instanceof \ReflectionIntersectionType)) { + throw new UnsupportedException(sprintf('"%s" type is not supported.', (string) $reflection)); + } + + if ($reflection instanceof \ReflectionUnionType) { + /** @var list $unionTypes */ + $unionTypes = array_map(fn (\ReflectionNamedType $t): string => $this->extractTypeFromReflection($t, $declaringClass), $reflection->getTypes()); + + return Type::fromString(implode('|', $unionTypes)); + } + + if ($reflection instanceof \ReflectionIntersectionType) { + /** @var list $intersectionTypes */ + $intersectionTypes = array_map(fn (\ReflectionNamedType $t): string => $this->extractTypeFromReflection($t, $declaringClass), $reflection->getTypes()); + + return Type::fromString(implode('&', $intersectionTypes)); + } + + $nullablePrefix = $reflection->allowsNull() ? '?' : ''; + $phpTypeOrClass = $reflection->getName(); + + if ('never' === $phpTypeOrClass || 'void' === $phpTypeOrClass) { + throw new UnsupportedException(sprintf('"%s" type is not supported.', $phpTypeOrClass)); + } + + if (\in_array($phpTypeOrClass, ['mixed', 'null'], true)) { + return Type::fromString($phpTypeOrClass); + } + + if ($declaringClass && 'self' === strtolower($phpTypeOrClass)) { + $phpTypeOrClass = $declaringClass->name; + } elseif ($declaringClass && 'parent' === strtolower($phpTypeOrClass) && $parent = $declaringClass->getParentClass()) { + $phpTypeOrClass = $parent->name; + } + + return Type::fromString($nullablePrefix.$phpTypeOrClass); + } +} diff --git a/src/Symfony/Component/Serializer/Type/TemplateExtractorInterface.php b/src/Symfony/Component/Serializer/Type/TemplateExtractorInterface.php new file mode 100644 index 0000000000000..d4a59898a954f --- /dev/null +++ b/src/Symfony/Component/Serializer/Type/TemplateExtractorInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Type; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface TemplateExtractorInterface +{ + /** + * @param \ReflectionClass $class + * + * @return list + */ + public function extractTemplateFromClass(\ReflectionClass $class): array; +} diff --git a/src/Symfony/Component/Serializer/Type/Type.php b/src/Symfony/Component/Serializer/Type/Type.php new file mode 100644 index 0000000000000..666b7686daf57 --- /dev/null +++ b/src/Symfony/Component/Serializer/Type/Type.php @@ -0,0 +1,588 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Type; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Exception\UnsupportedException; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +final class Type implements \Stringable +{ + /** + * @var array + */ + private static array $cache = []; + + /** + * @var class-string|null + */ + private readonly ?string $className; + + private readonly string $stringValue; + + /** + * @param class-string|null $className + * @param list $genericParameterTypes + * @param list $unionTypes + * @param list $intersectionTypes + */ + private function __construct( + private readonly string $name, + private readonly bool $isNullable = false, + string $className = null, + private readonly array $genericParameterTypes = [], + private readonly array $unionTypes = [], + private readonly array $intersectionTypes = [], + private readonly ?self $backingType = null, + ) { + if (1 === \count($this->unionTypes)) { + throw new InvalidArgumentException(sprintf('Cannot define only one union type for "%s" type.', $this->name)); + } + + if (1 === \count($this->intersectionTypes)) { + throw new InvalidArgumentException(sprintf('Cannot define only one intersection type for "%s" type.', $this->name)); + } + + if ('stdClass' === $className) { + $className = null; + } + + $this->className = $className; + $this->stringValue = $this->computeStringValue(); + } + + public function name(): string + { + return $this->name; + } + + /** + * @return class-string + */ + public function className(): string + { + if (!$this->isObject() && !$this->isEnum()) { + throw new LogicException(sprintf('Cannot get class on "%s" type as it\'s not an object nor an enum.', $this->name)); + } + + if (null === $this->className) { + throw new LogicException(sprintf('No class has been defined for "%s".', $this->name)); + } + + return $this->className; + } + + /** + * @return list + */ + public function genericParameterTypes(): array + { + return $this->genericParameterTypes; + } + + /** + * @return list + */ + public function unionTypes(): array + { + return $this->unionTypes; + } + + /** + * @return list + */ + public function intersectionTypes(): array + { + return $this->intersectionTypes; + } + + public function backingType(): self + { + if (!$this->isEnum()) { + throw new LogicException(sprintf('Cannot get backing type on "%s" type as it\'s not an enum.', $this->name)); + } + + if (null === $this->backingType) { + throw new LogicException(sprintf('No backing type has been defined for "%s".', $this->name)); + } + + return $this->backingType; + } + + public function isScalar(): bool + { + if ($this->isUnion()) { + return array_reduce($this->unionTypes, fn (bool $c, self $t): bool => $c && $t->isScalar(), true); + } + + if ($this->isIntersection()) { + foreach ($this->intersectionTypes as $type) { + if ($type->isScalar()) { + return true; + } + } + + return false; + } + + return \in_array($this->name, ['int', 'float', 'string', 'bool', 'null'], true); + } + + public function isNull(): bool + { + return 'null' === $this->name; + } + + public function isNullable(): bool + { + if ($this->isUnion()) { + foreach ($this->unionTypes as $type) { + if ($type->isNull() || $type->isNullable()) { + return true; + } + } + + return false; + } + + if ($this->isIntersection()) { + return array_reduce($this->intersectionTypes, fn (bool $c, self $t): bool => $c && $t->isNullable(), true); + } + + return $this->isNullable; + } + + public function isObject(): bool + { + return 'object' === $this->name; + } + + public function hasClass(): bool + { + return null !== $this->className; + } + + public function isEnum(): bool + { + return 'enum' === $this->name; + } + + public function isBackedEnum(): bool + { + return $this->isEnum() && null !== $this->backingType; + } + + public function isGeneric(): bool + { + return [] !== $this->genericParameterTypes; + } + + public function isUnion(): bool + { + return [] !== $this->unionTypes; + } + + public function isIntersection(): bool + { + return [] !== $this->intersectionTypes; + } + + public function isCollection(): bool + { + if ($this->isUnion()) { + return array_reduce($this->unionTypes, fn (bool $c, self $t): bool => $c && $t->isCollection(), true); + } + + if ($this->isIntersection()) { + foreach ($this->intersectionTypes as $type) { + if ($type->isCollection()) { + return true; + } + } + + return false; + } + + return \in_array($this->name, ['array', 'iterable'], true); + } + + public function isIterable(): bool + { + return 'iterable' === $this->name; + } + + public function isList(): bool + { + if (!$this->isCollection()) { + return false; + } + + $collectionKeyType = $this->collectionKeyType(); + if (!$collectionKeyType instanceof self) { + return false; + } + + return 'int' === $collectionKeyType->name(); + } + + public function isDict(): bool + { + if (!$this->isCollection()) { + return false; + } + + $collectionKeyType = $this->collectionKeyType(); + if (!$collectionKeyType instanceof self) { + return false; + } + + return 'string' === $collectionKeyType->name(); + } + + public function collectionKeyType(): self + { + if (!$this->isCollection()) { + throw new LogicException(sprintf('Cannot get collection key type on "%s" type as it\'s not a collection.', $this->name)); + } + + return $this->genericParameterTypes[0] ?? new self('mixed'); + } + + public function collectionValueType(): self + { + if (!$this->isCollection()) { + throw new LogicException(sprintf('Cannot get collection value type on "%s" type as it\'s not a collection.', $this->name)); + } + + return $this->genericParameterTypes[1] ?? new self('mixed'); + } + + public function __toString(): string + { + return $this->stringValue; + } + + public static function fromString(string $string): self + { + if (isset(self::$cache[$cacheKey = $string])) { + return self::$cache[$cacheKey]; + } + + if ('null' === $string) { + return self::$cache[$cacheKey] = new self('null'); + } + + if ($isNullable = str_starts_with($string, '?')) { + $string = substr($string, 1); + } + + if (\in_array($string, ['int', 'string', 'float', 'bool'])) { + return self::$cache[$cacheKey] = new self($string, $isNullable); + } + + $string = match ($string) { + 'array' => 'array', + 'list' => 'array', + 'iterable' => 'iterable', + default => $string, + }; + + if (is_subclass_of($string, \UnitEnum::class)) { + $reflection = new \ReflectionEnum($string); + + if ($reflection->isBacked() && null !== ($backingType = $reflection->getBackingType())) { + return self::$cache[$cacheKey] = new self('enum', $isNullable, $string, backingType: new self((string) $backingType)); + } + + return self::$cache[$cacheKey] = new self('enum', $isNullable, $string); + } + + if (class_exists($string) || interface_exists($string)) { + return self::$cache[$cacheKey] = new self('object', $isNullable, $string); + } + + $currentTypeString = ''; + $typeStrings = []; + $typesGlue = null; + $nestedLevel = 0; + + foreach (str_split(str_replace(' ', '', $string)) as $char) { + if ('<' === $char) { + ++$nestedLevel; + } + + if ('>' === $char) { + --$nestedLevel; + } + + if (\in_array($char, ['|', '&'], true) && 0 === $nestedLevel) { + if (null !== $typesGlue && $char !== $typesGlue) { + throw new UnsupportedException(sprintf('"%s" DNF type is not supported.', $string)); + } + + $typeStrings[] = $currentTypeString; + $typesGlue = $char; + $currentTypeString = ''; + + continue; + } + + $currentTypeString .= $char; + } + + $typeStrings[] = $currentTypeString; + + if (0 !== $nestedLevel) { + throw new InvalidArgumentException(sprintf('Invalid "%s" type.', $string)); + } + + if (\count($typeStrings) > 1) { + $nullable = false; + $types = []; + + foreach ($typeStrings as $typeString) { + if (str_starts_with($typeString, '?')) { + $nullable = true; + $typeString = substr($typeString, 1); + } + + if ('null' === $typeString) { + $nullable = true; + + continue; + } + + $type = self::fromString($typeString); + $types[] = $type; + } + + if ($nullable) { + $types[] = new self('null'); + } + + if ('&' === $typesGlue) { + return self::$cache[$cacheKey] = new self(implode('&', array_map(fn (Type $t): string => $t, $types)), intersectionTypes: $types); + } + + return self::$cache[$cacheKey] = new self(implode('|', array_map(fn (Type $t): string => $t, $types)), unionTypes: $types); + } + + $results = []; + if (preg_match('/^(?P[^<]+)<(?P.+)>$/', $string, $results)) { + $genericType = $results['type']; + $genericParameters = []; + $currentGenericParameter = ''; + $nestedLevel = 0; + $chars = str_split(str_replace(' ', '', $results['diamond'])); + + foreach ($chars as $i => $char) { + if (',' === $char && 0 === $nestedLevel) { + $genericParameters[] = $currentGenericParameter; + $currentGenericParameter = ''; + + continue; + } + + if ('<' === $char) { + ++$nestedLevel; + } + + if ('>' === $char) { + if (0 === $nestedLevel && $i !== \count($chars) - 1) { + throw new InvalidArgumentException(sprintf('Invalid "%s" type.', $string)); + } + + --$nestedLevel; + } + + $currentGenericParameter .= $char; + } + + if (0 !== $nestedLevel) { + throw new InvalidArgumentException(sprintf('Invalid "%s" type.', $string)); + } + + $genericParameters[] = $currentGenericParameter; + + if (\in_array($genericType, ['array', 'iterable'], true) && 1 === \count($genericParameters)) { + array_unshift($genericParameters, 'int'); + } + + if ('list' === $genericType && 1 === \count($genericParameters)) { + $genericType = 'array'; + array_unshift($genericParameters, 'int'); + } + + $type = $genericType; + $className = null; + + if (class_exists($genericType)) { + $type = 'object'; + $className = $genericType; + } + + return self::$cache[$cacheKey] = new self( + name: $type, + isNullable: $isNullable, + className: $className, + genericParameterTypes: array_map(fn (string $t): self => self::fromString($t), $genericParameters), + ); + } + + return self::$cache[$cacheKey] = new self($string, isNullable: $isNullable); + } + + public static function int(bool $nullable = false): self + { + return new self('int', isNullable: $nullable); + } + + public static function float(bool $nullable = false): self + { + return new self('float', isNullable: $nullable); + } + + public static function string(bool $nullable = false): self + { + return new self('string', isNullable: $nullable); + } + + public static function bool(bool $nullable = false): self + { + return new self('bool', isNullable: $nullable); + } + + public static function null(): self + { + return new self('null'); + } + + public static function mixed(): self + { + return new self('mixed'); + } + + public static function resource(): self + { + return new self('resource'); + } + + public static function callable(): self + { + return new self('callable'); + } + + public static function array(self $value = null, self $key = null, bool $nullable = false): self + { + return new self('array', isNullable: $nullable, genericParameterTypes: [ + $key ?? self::union(self::int(), self::string()), + $value ?? self::mixed(), + ]); + } + + public static function list(self $value = null, bool $nullable = false): self + { + return self::array($value, self::int(), $nullable); + } + + public static function dict(self $value = null, bool $nullable = false): self + { + return self::array($value, self::string(), $nullable); + } + + public static function iterable(self $value = null, self $key = null, bool $nullable = false): self + { + return new self('iterable', isNullable: $nullable, genericParameterTypes: [ + $key ?? self::union(self::int(), self::string()), + $value ?? self::mixed(), + ]); + } + + public static function iterableList(self $value = null, bool $nullable = false): self + { + return self::iterable($value, self::int(), $nullable); + } + + public static function iterableDict(self $value = null, bool $nullable = false): self + { + return self::iterable($value, self::string(), $nullable); + } + + public static function object(bool $nullable = false): self + { + return new self('object', isNullable: $nullable); + } + + /** + * @param class-string $className + * @param list $genericParameterTypes + */ + public static function class(string $className, bool $nullable = false, array $genericParameterTypes = []): self + { + return new self('object', isNullable: $nullable, className: $className, genericParameterTypes: $genericParameterTypes); + } + + public static function enum(string $enumClassName, bool $nullable = false): self + { + $reflection = new \ReflectionEnum($enumClassName); + + if ($reflection->isBacked() && null !== ($backingType = $reflection->getBackingType())) { + return new self('enum', isNullable: $nullable, className: $enumClassName, backingType: new self((string) $backingType)); + } + + return new self('enum', isNullable: $nullable, className: $enumClassName); + } + + public static function union(self ...$types): self + { + return new self( + implode('|', array_map(fn (self $t): string => (string) $t, $types)), + unionTypes: $types, + ); + } + + public static function intersection(self ...$types): self + { + return new self( + implode('&', array_map(fn (self $t): string => (string) $t, $types)), + intersectionTypes: $types, + ); + } + + private function computeStringValue(): string + { + if ($this->isUnion()) { + return implode('|', array_map(fn (self $t): string => (string) $t, $this->unionTypes)); + } + + if ($this->isIntersection()) { + return implode('&', array_map(fn (self $t): string => (string) $t, $this->intersectionTypes)); + } + + if ($this->isNull()) { + return 'null'; + } + + $name = $this->hasClass() ? $this->className() : $this->name(); + + if ($this->isGeneric()) { + $name .= sprintf('<%s>', implode(', ', $this->genericParameterTypes)); + } + + return ($this->isNullable() ? '?' : '').$name; + } +} diff --git a/src/Symfony/Component/Serializer/Type/TypeExtractorInterface.php b/src/Symfony/Component/Serializer/Type/TypeExtractorInterface.php new file mode 100644 index 0000000000000..792381dcaf597 --- /dev/null +++ b/src/Symfony/Component/Serializer/Type/TypeExtractorInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Type; + +/** + * @author Mathias Arlaud + * + * @experimental in 7.0 + */ +interface TypeExtractorInterface +{ + public function extractTypeFromProperty(\ReflectionProperty $property): Type; + + public function extractTypeFromFunctionReturn(\ReflectionFunctionAbstract $function): Type; + + public function extractTypeFromParameter(\ReflectionParameter $parameter): Type; +} diff --git a/src/Symfony/Component/Serializer/Type/TypeGenericsHelper.php b/src/Symfony/Component/Serializer/Type/TypeGenericsHelper.php new file mode 100644 index 0000000000000..f24b327fdd63a --- /dev/null +++ b/src/Symfony/Component/Serializer/Type/TypeGenericsHelper.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Type; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Mathias Arlaud + * + * @internal + */ +final class TypeGenericsHelper +{ + public function __construct( + private readonly TypeExtractorInterface $typeExtractor, + ) { + } + + /** + * @param class-string $className + * + * @return array + */ + public function classGenericTypes(string $className, Type $type): array + { + if (!$this->typeExtractor instanceof TemplateExtractorInterface) { + return []; + } + + $findClassType = static function (string $className, Type $type) use (&$findClassType): ?Type { + if ($type->hasClass() && $type->className() === $className) { + return $type; + } + + foreach ($type->genericParameterTypes() as $genericParameterType) { + if (null !== $t = $findClassType($className, $genericParameterType)) { + return $t; + } + } + + foreach ($type->unionTypes() as $unionType) { + if (null !== $t = $findClassType($className, $unionType)) { + return $t; + } + } + + foreach ($type->intersectionTypes() as $intersectionType) { + if (null !== $t = $findClassType($className, $intersectionType)) { + return $t; + } + } + + return null; + }; + + $classType = $findClassType($className, $type); + if (null === $classType) { + return []; + } + + $genericParameterTypes = $classType->genericParameterTypes(); + $templates = $this->typeExtractor->extractTemplateFromClass(new \ReflectionClass($className)); + + if (\count($templates) !== \count($genericParameterTypes)) { + throw new InvalidArgumentException(sprintf('Given %d generic parameters in "%s", but %d templates are defined in "%2$s".', \count($genericParameterTypes), $className, \count($templates))); + } + + $genericTypes = []; + foreach ($genericParameterTypes as $i => $genericParameterType) { + $genericTypes[$templates[$i]] = $genericParameterType; + } + + return $genericTypes; + } + + /** + * @param array $genericTypes + */ + public function replaceGenericTypes(Type $type, array $genericTypes): Type + { + $typeString = (string) $type; + + if (isset($genericTypes[$typeString])) { + return Type::fromString($genericTypes[$typeString]); + } + + if ([] !== $type->genericParameterTypes()) { + return Type::fromString(sprintf( + '%s<%s>', + $type->name(), + implode(', ', array_map(fn (Type $t): string => $this->replaceGenericTypes($t, $genericTypes), $type->genericParameterTypes())), + )); + } + + if ([] !== $type->unionTypes()) { + return Type::fromString(implode('|', array_map(fn (Type $t): string => $this->replaceGenericTypes($t, $genericTypes), $type->unionTypes()))); + } + + if ([] !== $type->intersectionTypes()) { + return Type::fromString(implode('&', array_map(fn (Type $t): string => $this->replaceGenericTypes($t, $genericTypes), $type->intersectionTypes()))); + } + + return Type::fromString($typeString); + } +} diff --git a/src/Symfony/Component/Serializer/Type/TypeNameResolver.php b/src/Symfony/Component/Serializer/Type/TypeNameResolver.php new file mode 100644 index 0000000000000..c28cca3ecbf3d --- /dev/null +++ b/src/Symfony/Component/Serializer/Type/TypeNameResolver.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Type; + +use phpDocumentor\Reflection\Types\ContextFactory; +use Symfony\Component\Serializer\Exception\LogicException; + +/** + * @author Mathias Arlaud + * + * @internal + */ +final class TypeNameResolver +{ + /** + * @param class-string $className + * @param array $uses + * @param list $templateNames + */ + public function __construct( + private readonly string $className, + private readonly string $namespace, + private readonly array $uses, + private readonly array $templateNames, + ) { + } + + /** + * @param \ReflectionClass $class + * @param list $templateNames + */ + public static function createForClass(\ReflectionClass $class, array $templateNames): self + { + $context = (new ContextFactory())->createFromReflector($class); + $namespace = $context->getNamespace(); + + /** @var array $uses */ + $uses = $context->getNamespaceAliases(); + + /** @var class-string $className */ + $className = str_replace($namespace.'\\', '', $class->getName()); + + return new self($className, $namespace, $uses, $templateNames); + } + + /** + * @return class-string + */ + public function resolveRootClass(): string + { + return $this->resolve($this->className); + } + + /** + * @return class-string + */ + public function resolveParentClass(): string + { + $rootClassName = $this->resolveRootClass(); + + if (false === $parentClass = (new \ReflectionClass($rootClassName))->getParentClass()) { + throw new LogicException(sprintf('"%s" class do not extend any class.', $rootClassName)); + } + + /** @var class-string $parentClassName */ + $parentClassName = str_replace($this->namespace.'\\', '', $parentClass->getName()); + + return $this->resolve($parentClassName); + } + + /** + * @template T of string|class-string + * + * @param T $name + * + * @return T + */ + public function resolve(string $name): string + { + if (\in_array($name, $this->templateNames)) { + return $name; + } + + if (str_starts_with($name, '\\')) { + /** @var T $name */ + $name = ltrim($name, '\\'); + + return $name; + } + + $nameParts = explode('\\', $name); + $usedPart = $nameParts[0]; + + if (!isset($this->uses[$usedPart])) { + /** @var T $name */ + $name = sprintf('%s\\%s', $this->namespace, $name); + + return $name; + } + + if (1 === \count($nameParts)) { + /** @var T $name */ + $name = $this->uses[$usedPart]; + + return $name; + } + + array_shift($nameParts); + + /** @var T $name */ + $name = sprintf('%s\\%s', $this->uses[$usedPart], implode('\\', $nameParts)); + + return $name; + } +} diff --git a/src/Symfony/Component/Serializer/composer.json b/src/Symfony/Component/Serializer/composer.json index 870779717b827..ae63a4f033c25 100644 --- a/src/Symfony/Component/Serializer/composer.json +++ b/src/Symfony/Component/Serializer/composer.json @@ -17,10 +17,13 @@ ], "require": { "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8" + "psr/container": "^2.0|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/var-exporter": "^6.4|^7.0" }, "require-dev": { "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", + "phpstan/phpdoc-parser": "^1.0", "seld/jsonlint": "^1.10", "symfony/cache": "^6.4|^7.0", "symfony/config": "^6.4|^7.0", @@ -38,7 +41,6 @@ "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0", - "symfony/var-exporter": "^6.4|^7.0", "symfony/yaml": "^6.4|^7.0" }, "conflict": { diff --git a/src/Symfony/Component/String/Resources/WcswidthDataGenerator.php b/src/Symfony/Component/String/Resources/WcswidthDataGenerator.php index 0526da166e72f..6425eccd476c9 100644 --- a/src/Symfony/Component/String/Resources/WcswidthDataGenerator.php +++ b/src/Symfony/Component/String/Resources/WcswidthDataGenerator.php @@ -46,7 +46,7 @@ private function writeWideWidthData(): void $version = $matches[1]; - if (!preg_match_all('/^([A-H\d]{4,})(?:\.\.([A-H\d]{4,}))?;[W|F]/m', $content, $matches, \PREG_SET_ORDER)) { + if (!preg_match_all('/^([A-H\d]{4,})(?:\.\.([A-H\d]{4,}))? +; [W|F]/m', $content, $matches, \PREG_SET_ORDER)) { throw new RuntimeException('The wide width pattern did not match anything.'); } diff --git a/src/Symfony/Component/String/Resources/data/wcswidth_table_wide.php b/src/Symfony/Component/String/Resources/data/wcswidth_table_wide.php index 5a647e67bf30f..8314c8fd504c2 100644 --- a/src/Symfony/Component/String/Resources/data/wcswidth_table_wide.php +++ b/src/Symfony/Component/String/Resources/data/wcswidth_table_wide.php @@ -3,8 +3,8 @@ /* * This file has been auto-generated by the Symfony String Component for internal use. * - * Unicode version: 15.0.0 - * Date: 2022-10-05T17:16:36+02:00 + * Unicode version: 15.1.0 + * Date: 2023-09-13T11:47:12+00:00 */ return [ @@ -166,7 +166,7 @@ ], [ 12272, - 12283, + 12287, ], [ 12288, @@ -396,6 +396,10 @@ 12736, 12771, ], + [ + 12783, + 12783, + ], [ 12784, 12799, @@ -1110,6 +1114,14 @@ ], [ 191457, + 191471, + ], + [ + 191472, + 192093, + ], + [ + 192094, 194559, ], [ diff --git a/src/Symfony/Component/String/Resources/data/wcswidth_table_zero.php b/src/Symfony/Component/String/Resources/data/wcswidth_table_zero.php index 9ae7330325291..e5b26a21515ea 100644 --- a/src/Symfony/Component/String/Resources/data/wcswidth_table_zero.php +++ b/src/Symfony/Component/String/Resources/data/wcswidth_table_zero.php @@ -3,8 +3,8 @@ /* * This file has been auto-generated by the Symfony String Component for internal use. * - * Unicode version: 15.0.0 - * Date: 2022-10-05T17:16:37+02:00 + * Unicode version: 15.1.0 + * Date: 2023-09-13T11:47:13+00:00 */ return [ diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/README.md b/src/Symfony/Component/Translation/Bridge/Crowdin/README.md index bce877e2a4083..bd4b7e4e3de41 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/README.md +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/README.md @@ -23,11 +23,7 @@ where: Sponsor ------- -This bridge for Symfony 6.3 is [backed][1] by [Crowdin][2]. - -Crowdin is a cloud-based localization management software helping teams to go global and stay agile. - -Help Symfony by [sponsoring][3] its development! +Help Symfony by [sponsoring][1] its development! Resources --------- @@ -37,6 +33,4 @@ Resources [send Pull Requests](https://github.com/symfony/symfony/pulls) in the [main Symfony repository](https://github.com/symfony/symfony) -[1]: https://symfony.com/backers -[2]: https://crowdin.com -[3]: https://symfony.com/sponsor +[1]: https://symfony.com/sponsor diff --git a/src/Symfony/Component/Translation/README.md b/src/Symfony/Component/Translation/README.md index 8c981a60ef501..32e4017b72ed3 100644 --- a/src/Symfony/Component/Translation/README.md +++ b/src/Symfony/Component/Translation/README.md @@ -26,11 +26,7 @@ echo $translator->trans('Hello World!'); // outputs « Bonjour ! » Sponsor ------- -The Translation component for Symfony 6.3 is [backed][1] by: - - * [Crowdin][2], a cloud-based localization management software helping teams to go global and stay agile. - -Help Symfony by [sponsoring][3] its development! +Help Symfony by [sponsoring][1] its development! Resources --------- @@ -41,6 +37,4 @@ Resources [send Pull Requests](https://github.com/symfony/symfony/pulls) in the [main Symfony repository](https://github.com/symfony/symfony) -[1]: https://symfony.com/backers -[2]: https://crowdin.com -[3]: https://symfony.com/sponsor +[1]: https://symfony.com/sponsor diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 9329f7b910570..43a23446736f7 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -15,18 +15,24 @@ CHANGELOG * Remove the annotation reader parameter from the constructor signature of `AnnotationLoader` * Remove `ValidatorBuilder::setDoctrineAnnotationReader()` * Remove `ValidatorBuilder::addDefaultDoctrineAnnotationReader()` + * Remove `ValidatorBuilder::enableAnnotationMapping()`, use `ValidatorBuilder::enableAttributeMapping()` instead + * Remove `ValidatorBuilder::disableAnnotationMapping()`, use `ValidatorBuilder::disableAttributeMapping()` instead + * Remove `AnnotationLoader`, use `AttributeLoader` instead 6.4 --- + * Add `is_valid` function to the `Expression` constraint, its behavior is the same as `ValidatorInterface::validate` * Allow single integer for the `versions` option of the `Uuid` constraint * Allow single constraint to be passed to the `constraints` option of the `When` constraint * Deprecate Doctrine annotations support in favor of native attributes - * Deprecate passing an annotation reader to the constructor signature of `AnnotationLoader` * Deprecate `ValidatorBuilder::setDoctrineAnnotationReader()` * Deprecate `ValidatorBuilder::addDefaultDoctrineAnnotationReader()` * Add `number`, `finite-number` and `finite-float` types to `Type` constraint * Add the `withSeconds` option to the `Time` constraint that allows to pass time without seconds + * Deprecate `ValidatorBuilder::enableAnnotationMapping()`, use `ValidatorBuilder::enableAttributeMapping()` instead + * Deprecate `ValidatorBuilder::disableAnnotationMapping()`, use `ValidatorBuilder::disableAttributeMapping()` instead + * Deprecate `AnnotationLoader`, use `AttributeLoader` instead 6.3 --- diff --git a/src/Symfony/Component/Validator/Constraints/Callback.php b/src/Symfony/Component/Validator/Constraints/Callback.php index 2f0defe2cf426..c4bf70ea93b74 100644 --- a/src/Symfony/Component/Validator/Constraints/Callback.php +++ b/src/Symfony/Component/Validator/Constraints/Callback.php @@ -26,7 +26,7 @@ class Callback extends Constraint public function __construct(array|string|callable $callback = null, array $groups = null, mixed $payload = null, array $options = []) { - // Invocation through annotations with an array parameter only + // Invocation through attributes with an array parameter only if (\is_array($callback) && 1 === \count($callback) && isset($callback['value'])) { $callback = $callback['value']; } diff --git a/src/Symfony/Component/Validator/Constraints/ExpressionValidator.php b/src/Symfony/Component/Validator/Constraints/ExpressionValidator.php index c00875bcb398b..9c5ff6576cb22 100644 --- a/src/Symfony/Component/Validator/Constraints/ExpressionValidator.php +++ b/src/Symfony/Component/Validator/Constraints/ExpressionValidator.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Validator\Constraints; +use Symfony\Component\ExpressionLanguage\ExpressionFunction; +use Symfony\Component\ExpressionLanguage\ExpressionFunctionProviderInterface; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; @@ -20,13 +22,16 @@ * @author Fabien Potencier * @author Bernhard Schussek */ -class ExpressionValidator extends ConstraintValidator +class ExpressionValidator extends ConstraintValidator implements ExpressionFunctionProviderInterface { - private ?ExpressionLanguage $expressionLanguage; + private ExpressionLanguage $expressionLanguage; public function __construct(ExpressionLanguage $expressionLanguage = null) { - $this->expressionLanguage = $expressionLanguage; + if ($expressionLanguage) { + $this->expressionLanguage = clone $expressionLanguage; + $this->expressionLanguage->registerProvider($this); + } } public function validate(mixed $value, Constraint $constraint): void @@ -38,6 +43,7 @@ public function validate(mixed $value, Constraint $constraint): void $variables = $constraint->values; $variables['value'] = $value; $variables['this'] = $this->context->getObject(); + $variables['context'] = $this->context; if ($constraint->negate xor $this->getExpressionLanguage()->evaluate($constraint->expression, $variables)) { $this->context->buildViolation($constraint->message) @@ -47,8 +53,27 @@ public function validate(mixed $value, Constraint $constraint): void } } + public function getFunctions(): array + { + return [ + new ExpressionFunction('is_valid', function (...$arguments) { + return sprintf( + '0 === $context->getValidator()->inContext($context)->validate(%s)->getViolations()->count()', + implode(', ', $arguments) + ); + }, function (array $variables, ...$arguments): bool { + return 0 === $variables['context']->getValidator()->inContext($variables['context'])->validate(...$arguments)->getViolations()->count(); + }), + ]; + } + private function getExpressionLanguage(): ExpressionLanguage { - return $this->expressionLanguage ??= new ExpressionLanguage(); + if (!isset($this->expressionLanguage)) { + $this->expressionLanguage = new ExpressionLanguage(); + $this->expressionLanguage->registerProvider($this); + } + + return $this->expressionLanguage; } } diff --git a/src/Symfony/Component/Validator/Constraints/When.php b/src/Symfony/Component/Validator/Constraints/When.php index 5dc9a8ed159bc..807410d8166d5 100644 --- a/src/Symfony/Component/Validator/Constraints/When.php +++ b/src/Symfony/Component/Validator/Constraints/When.php @@ -16,9 +16,6 @@ use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Exception\LogicException; -/** - * @Target({"CLASS", "PROPERTY", "METHOD", "ANNOTATION"}) - */ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class When extends Composite { diff --git a/src/Symfony/Component/Validator/Mapping/Loader/AnnotationLoader.php b/src/Symfony/Component/Validator/Mapping/Loader/AttributeLoader.php similarity index 88% rename from src/Symfony/Component/Validator/Mapping/Loader/AnnotationLoader.php rename to src/Symfony/Component/Validator/Mapping/Loader/AttributeLoader.php index a8d09da2a5690..9674122b64115 100644 --- a/src/Symfony/Component/Validator/Mapping/Loader/AnnotationLoader.php +++ b/src/Symfony/Component/Validator/Mapping/Loader/AttributeLoader.php @@ -23,8 +23,9 @@ * * @author Bernhard Schussek * @author Alexander M. Turek + * @author Alexandre Daubois */ -class AnnotationLoader implements LoaderInterface +class AttributeLoader implements LoaderInterface { public function loadClassMetadata(ClassMetadata $metadata): bool { @@ -32,7 +33,7 @@ public function loadClassMetadata(ClassMetadata $metadata): bool $className = $reflClass->name; $success = false; - foreach ($this->getAnnotations($reflClass) as $constraint) { + foreach ($this->getAttributes($reflClass) as $constraint) { if ($constraint instanceof GroupSequence) { $metadata->setGroupSequence($constraint->groups); } elseif ($constraint instanceof GroupSequenceProvider) { @@ -46,7 +47,7 @@ public function loadClassMetadata(ClassMetadata $metadata): bool foreach ($reflClass->getProperties() as $property) { if ($property->getDeclaringClass()->name === $className) { - foreach ($this->getAnnotations($property) as $constraint) { + foreach ($this->getAttributes($property) as $constraint) { if ($constraint instanceof Constraint) { $metadata->addPropertyConstraint($property->name, $constraint); } @@ -58,7 +59,7 @@ public function loadClassMetadata(ClassMetadata $metadata): bool foreach ($reflClass->getMethods() as $method) { if ($method->getDeclaringClass()->name === $className) { - foreach ($this->getAnnotations($method) as $constraint) { + foreach ($this->getAttributes($method) as $constraint) { if ($constraint instanceof Callback) { $constraint->callback = $method->getName(); @@ -79,7 +80,7 @@ public function loadClassMetadata(ClassMetadata $metadata): bool return $success; } - private function getAnnotations(\ReflectionMethod|\ReflectionClass|\ReflectionProperty $reflection): iterable + private function getAttributes(\ReflectionMethod|\ReflectionClass|\ReflectionProperty $reflection): iterable { foreach ($reflection->getAttributes(GroupSequence::class) as $attribute) { yield $attribute->newInstance(); diff --git a/src/Symfony/Component/Validator/Resources/translations/validators.mk.xlf b/src/Symfony/Component/Validator/Resources/translations/validators.mk.xlf new file mode 100644 index 0000000000000..eb15989839b8a --- /dev/null +++ b/src/Symfony/Component/Validator/Resources/translations/validators.mk.xlf @@ -0,0 +1,431 @@ + + + + + + This value should be false. + Оваа вредност треба да биде лажна. + + + This value should be true. + Оваа вредност треба да биде вистинита. + + + This value should be of type {{ type }}. + Оваа вредност треба да биде од типот {{ type }}. + + + This value should be blank. + Оваа вредност треба да биде празна. + + + The value you selected is not a valid choice. + Вредноста што ја одбравте не е валиден избор. + + + You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices. + Мора да одберете најмалку {{ limit }} избор.|Мора да одберете најмалку {{ limit }} изброи. + + + You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices. + Може да одберете најмногу {{ limit }} избор.|Мора да одберете најмногу {{ limit }} избори. + + + One or more of the given values is invalid. + Една или повеќе од дадените вредности не се валидни. + + + This field was not expected. + Ова поле не беше очекувано. + + + This field is missing. + Ова поле недостига. + + + This value is not a valid date. + Оваа вредност не е валиден датум. + + + This value is not a valid datetime. + Оваа вредност не е валиден датум и време. + + + This value is not a valid email address. + Оваа вредност не е валидна адреса за е-пошта. + + + The file could not be found. + Датотеката не е најдена. + + + The file is not readable. + Датотеката не може да биде прочитана. + + + The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}. + Датотеката е премногу голема ({{ size }} {{ suffix }}). Максималната дозволена големина е {{ limit }} {{ suffix }}. + + + The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}. + Миме типот на датотеката не е валиден ({{ type }}). Дозволените миме типови се {{ types }}. + + + This value should be {{ limit }} or less. + Оваа вредност треба да биде {{ limit }} или помалку. + + + This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less. + Оваа вредност е предолга. Треба да содржи {{ limit }} карактер или помалку.|Оваа вредност е предолга. Треба да содржи {{ limit }} карактери или помалку. + + + This value should be {{ limit }} or more. + Оваа вредност треба да е {{ limit }} или повеќе. + + + This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more. + Оваа вредност е прекратка. Треба да содржи {{ limit }} карактер или повеќе.|Оваа вредност е прекратка. Треба да содржи {{ limit }} карактери или повеќе. + + + This value should not be blank. + Ова поле не може да биде празно. + + + This value should not be null. + Оваа вредност не може да биде ништо (null). + + + This value should be null. + Оваа вредност треба да е ништо (null). + + + This value is not valid. + Оваа вредност не е валидна. + + + This value is not a valid time. + Оваа вредност не е валидно време. + + + This value is not a valid URL. + Оваа вредност не е валиден URL. + + + The two values should be equal. + Двете вредности треба да се еднакви. + + + The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}. + Датотеката е премногу голема. Максималната дозволена големина е {{ limit }} {{ suffix }}. + + + The file is too large. + Датотеката е премногу голема. + + + The file could not be uploaded. + Датотеката не може да биде подигната. + + + This value should be a valid number. + Оваа вредност треба да е валиден број. + + + This file is not a valid image. + Оваа датотека не е валидна слика. + + + This is not a valid IP address. + Ова не е валидна IP адреса. + + + This value is not a valid language. + Оваа вредност не е валиден јазик. + + + This value is not a valid locale. + Оваа вредност не е валидна локализација. + + + This value is not a valid country. + Оваа вредност не е валидна земја. + + + This value is already used. + Оваа вредност веќе се користи. + + + The size of the image could not be detected. + Големината на сликата не може да се детектира. + + + The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. + Ширината на сликата е преголема ({{ width }}px). Максималната дозволена ширина е {{ max_width }}px. + + + The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. + Ширината на сликата е премала ({{ width }}px). Минималната дозволена ширина е {{ min_width }}px. + + + The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. + Висината на сликата е преголема ({{ height }}px). Максималната дозволена висина е {{ max_height }}px. + + + The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. + Висината на сликата е премала ({{ height }}px). Минималната дозволена висина е {{ min_height }}px. + + + This value should be the user's current password. + Оваа вредност треба да биде сегашната лозинка на корисникот. + + + This value should have exactly {{ limit }} character.|This value should have exactly {{ limit }} characters. + Оваа вредност треба да има точно {{ limit }} карактер.|Оваа вредност треба да има точно {{ limit }} карактери. + + + The file was only partially uploaded. + Датотеката е само делумно подигната. + + + No file was uploaded. + Датотеката не е подигната. + + + No temporary folder was configured in php.ini. + Ниту една привремена папка не е конфигурирана во php.ini. + + + Cannot write temporary file to disk. + Не може да се напише привремена датотека на дискот. + + + A PHP extension caused the upload to fail. + PHP екстензијата предизвика подигнувањето да биде неуспешно. + + + This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more. + Оваа колекција треба да содржи {{ limit }} елемент или повеќе.|Оваа колекција треба да содржи {{ limit }} елементи или повеќе. + + + This collection should contain {{ limit }} element or less.|This collection should contain {{ limit }} elements or less. + Оваа колекција треба да содржи {{ limit }} елемент или помалку.|Оваа колекција треба да содржи {{ limit }} елементи или помалку. + + + This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements. + Оваа колекција треба да содржи точно {{ limit }} елемент.|Оваа колекција треба да содржи точно {{ limit }} елементи. + + + Invalid card number. + Бројот на картичката не е валиден. + + + Unsupported card type or invalid card number. + Неподдржан тип на картичка или бројот на картичката не е валиден. + + + This is not a valid International Bank Account Number (IBAN). + Ова не е валиден број на меѓународна банкарска сметка (IBAN). + + + This value is not a valid ISBN-10. + Оваа вредност не е валиден ISBN-10. + + + This value is not a valid ISBN-13. + Оваа вредност не е валиден ISBN-13. + + + This value is neither a valid ISBN-10 nor a valid ISBN-13. + Оваа вредност не е ниту валиден ISBN-10 ниту валиден ISBN-13. + + + This value is not a valid ISSN. + Оваа вредност не е валиден ISSN. + + + This value is not a valid currency. + Оваа вредност не е валидна валута. + + + This value should be equal to {{ compared_value }}. + Оваа вредност треба да биде еднаква на {{ compared_value }}. + + + This value should be greater than {{ compared_value }}. + Оваа вредност треба да е поголема од {{ compared_value }}. + + + This value should be greater than or equal to {{ compared_value }}. + Оваа вредност треба да е поголема или еднаква на {{ compared_value }}. + + + This value should be identical to {{ compared_value_type }} {{ compared_value }}. + Оваа вредност треба да е идентична на {{ compared_value_type }} {{ compared_value }}. + + + This value should be less than {{ compared_value }}. + Оваа вредност треба да е помала од {{ compared_value }}. + + + This value should be less than or equal to {{ compared_value }}. + Оваа вредност треба да е помала или еднаква на {{ compared_value }}. + + + This value should not be equal to {{ compared_value }}. + Оваа вредност треба да не биде еднаква на {{ compared_value }}. + + + This value should not be identical to {{ compared_value_type }} {{ compared_value }}. + Оваа вредност треба да не биде идентична со {{ compared_value_type }} {{ compared_value }}. + + + The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. + Соодносот на сликата е преголем ({{ ratio }}).Максималниот дозволен сооднос е {{ max_ratio }}. + + + The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. + Соодносот на сликата е премал ({{ ratio }}). Минималниот дозволен сооднос е {{ min_ratio }}. + + + The image is square ({{ width }}x{{ height }}px). Square images are not allowed. + Сликата е квадратна ({{ width }}x{{ height }}px). Квадратни слики не се дозволени. + + + The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed. + Сликата е ориентирана кон пејзаж ({{ width }}x{{ height }}px). Сликите ориентирани кон пејзаж не се дозволени. + + + The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed. + Сликата е ориентирана кон портрет ({{ width }}x{{ height }}px). Сликите ориентирани кон портрет не се дозволени. + + + An empty file is not allowed. + Празна датотека не е дозволена. + + + The host could not be resolved. + Хостот е недостапен. + + + This value does not match the expected {{ charset }} charset. + Оваа вредност не се совпаѓа со очекуваниот {{ charset }} сет на карактери (charset). + + + This is not a valid Business Identifier Code (BIC). + Ова не е валиден бизнис идентификациски код (BIC). + + + Error + Грешка + + + This is not a valid UUID. + Ова не е валиден универзален уникатен идентификатор (UUID). + + + This value should be a multiple of {{ compared_value }}. + Оваа вредност треба да биде повеќекратна од {{ compared_value }}. + + + This Business Identifier Code (BIC) is not associated with IBAN {{ iban }}. + Овој бизнис идентификациски код (BIC) не е поврзан со IBAN {{ iban }}. + + + This value should be valid JSON. + Оваа вредност треба да биде валиден JSON. + + + This collection should contain only unique elements. + Оваа колекција треба да содржи само уникатни елементи. + + + This value should be positive. + Оваа вредност треба да биде позитивна. + + + This value should be either positive or zero. + Оваа вредност треба да биде или позитивна или нула. + + + This value should be negative. + Оваа вредност треба да биде негативна. + + + This value should be either negative or zero. + Оваа вредност треба да биде или негативна или нула. + + + This value is not a valid timezone. + Оваа вредност не е валидна временска зона. + + + This password has been leaked in a data breach, it must not be used. Please use another password. + Оваа лозинка е компромитирана и не смее да биде користена. Ве молиме употребете друга лозинка. + + + This value should be between {{ min }} and {{ max }}. + Оваа вредност треба да е помеѓу {{ min }} и {{ max }}. + + + This value is not a valid hostname. + Оваа вредност не е валидно име за мрежниот сметач (hostname). + + + The number of elements in this collection should be a multiple of {{ compared_value }}. + Бројот на елементи во оваа колекција треба да биде повеќекратен од {{ compared_value }}. + + + This value should satisfy at least one of the following constraints: + Оваа вредност треба да задоволува најмалку едно од следните ограничувања: + + + Each element of this collection should satisfy its own set of constraints. + Секој елемент од оваа колекција треба да задоволува сопствен сет на ограничувања. + + + This value is not a valid International Securities Identification Number (ISIN). + Оваа вредност не е важечки меѓународен идентификациски број за хартии од вредност (ISIN). + + + This value should be a valid expression. + Оваа вредност треба да биде валиден израз. + + + This value is not a valid CSS color. + Оваа вредност не е валидна CSS боја. + + + This value is not a valid CIDR notation. + Оваа вредност не е валидна CIDR ознака. + + + The value of the netmask should be between {{ min }} and {{ max }}. + Вредноста на мрежната маска (netmask) треба да биде помеѓу {{ min }} и {{ max }}. + + + The filename is too long. It should have {{ filename_max_length }} character or less.|The filename is too long. It should have {{ filename_max_length }} characters or less. + Името на датотеката е предолго. Треба да има {{ filename_max_length }} карактер има помалку.|Името на датотеката е предолго. Треба да има {{ filename_max_length }} карактери или помалку. + + + The password strength is too low. Please use a stronger password. + Оваа лозинка е премногу едноставна. Ве молиме користете посилна лозинка. + + + This value contains characters that are not allowed by the current restriction-level. + Оваа вредност содржи карактери кои не се дозволени од тековното ниво на ограничување. + + + Using invisible characters is not allowed. + Користењето на невидливи знаци не е дозволено. + + + Mixing numbers from different scripts is not allowed. + Не е дозволено мешање на броеви од различни скрипти. + + + Using hidden overlay characters is not allowed. + Не е дозволено користење на скриени знаци за преклопување. + + + + diff --git a/src/Symfony/Component/Validator/Tests/Command/DebugCommandTest.php b/src/Symfony/Component/Validator/Tests/Command/DebugCommandTest.php index 41eab9d8f2e21..660905eba27da 100644 --- a/src/Symfony/Component/Validator/Tests/Command/DebugCommandTest.php +++ b/src/Symfony/Component/Validator/Tests/Command/DebugCommandTest.php @@ -16,7 +16,7 @@ use Symfony\Component\Validator\Command\DebugCommand; use Symfony\Component\Validator\Mapping\Factory\LazyLoadingMetadataFactory; use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; use Symfony\Component\Validator\Tests\Dummy\DummyClassOne; /** @@ -26,7 +26,7 @@ class DebugCommandTest extends TestCase { public function testOutputWithClassArgument() { - $command = new DebugCommand(new LazyLoadingMetadataFactory(new AnnotationLoader())); + $command = new DebugCommand(new LazyLoadingMetadataFactory(new AttributeLoader())); $tester = new CommandTester($command); $tester->execute(['class' => DummyClassOne::class], ['decorated' => false]); @@ -82,7 +82,7 @@ public function testOutputWithClassArgument() public function testOutputWithPathArgument() { - $command = new DebugCommand(new LazyLoadingMetadataFactory(new AnnotationLoader())); + $command = new DebugCommand(new LazyLoadingMetadataFactory(new AttributeLoader())); $tester = new CommandTester($command); $tester->execute(['class' => __DIR__.'/../Dummy'], ['decorated' => false]); diff --git a/src/Symfony/Component/Validator/Tests/ConstraintTest.php b/src/Symfony/Component/Validator/Tests/ConstraintTest.php index 3d233c17815b7..80e33c7b722a8 100644 --- a/src/Symfony/Component/Validator/Tests/ConstraintTest.php +++ b/src/Symfony/Component/Validator/Tests/ConstraintTest.php @@ -245,7 +245,7 @@ public function testOptionsWithInvalidInternalPointer() $this->assertEquals('foo', $constraint->property1); } - public function testAnnotationSetUndefinedDefaultOption() + public function testAttributeSetUndefinedDefaultOption() { $this->expectException(ConstraintDefinitionException::class); $this->expectExceptionMessage('No default option is configured for constraint "Symfony\Component\Validator\Tests\Fixtures\ConstraintB".'); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/BicValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/BicValidatorTest.php index c6b474ad3ee8c..699979f3e6f63 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/BicValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/BicValidatorTest.php @@ -16,7 +16,7 @@ use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Exception\UnexpectedValueException; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; class BicValidatorTest extends ConstraintValidatorTestCase @@ -74,7 +74,7 @@ public function testInvalidComparisonToPropertyPath() public function testInvalidComparisonToPropertyPathFromAttribute() { $classMetadata = new ClassMetadata(BicDummy::class); - (new AnnotationLoader())->loadClassMetadata($classMetadata); + (new AttributeLoader())->loadClassMetadata($classMetadata); [$constraint] = $classMetadata->properties['bic1']->constraints; @@ -116,7 +116,7 @@ public function testInvalidComparisonToValue() public function testInvalidComparisonToValueFromAttribute() { $classMetadata = new ClassMetadata(BicDummy::class); - (new AnnotationLoader())->loadClassMetadata($classMetadata); + (new AttributeLoader())->loadClassMetadata($classMetadata); [$constraint] = $classMetadata->properties['bic1']->constraints; diff --git a/src/Symfony/Component/Validator/Tests/Constraints/CallbackValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/CallbackValidatorTest.php index 084b192b64371..e888baa7a6596 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/CallbackValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/CallbackValidatorTest.php @@ -205,7 +205,7 @@ public function testConstraintGetTargets() $this->assertEquals($targets, $constraint->getTargets()); } - // Should succeed. Needed when defining constraints as annotations. + // Should succeed. Needed when defining constraints as attributes. public function testNoConstructorArguments() { $constraint = new Callback(); @@ -213,14 +213,14 @@ public function testNoConstructorArguments() $this->assertSame([Constraint::CLASS_CONSTRAINT, Constraint::PROPERTY_CONSTRAINT], $constraint->getTargets()); } - public function testAnnotationInvocationSingleValued() + public function testAttributeInvocationSingleValued() { $constraint = new Callback(['value' => 'validateStatic']); $this->assertEquals(new Callback('validateStatic'), $constraint); } - public function testAnnotationInvocationMultiValued() + public function testAttributeInvocationMultiValued() { $constraint = new Callback(['value' => [__CLASS__.'_Class', 'validateCallback']]); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/CardSchemeTest.php b/src/Symfony/Component/Validator/Tests/Constraints/CardSchemeTest.php index e57481917a7b5..af0781b6556b9 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/CardSchemeTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/CardSchemeTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\CardScheme; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class CardSchemeTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(CardSchemeDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/CascadeTest.php b/src/Symfony/Component/Validator/Tests/Constraints/CascadeTest.php index d2ac6a2059e4d..ee3798079dc39 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/CascadeTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/CascadeTest.php @@ -15,14 +15,14 @@ use Symfony\Component\Validator\Constraints\Cascade; use Symfony\Component\Validator\Mapping\CascadingStrategy; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class CascadeTest extends TestCase { public function testCascadeAttribute() { $metadata = new ClassMetadata(CascadeDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertSame(CascadingStrategy::NONE, $metadata->getCascadingStrategy()); self::assertTrue($loader->loadClassMetadata($metadata)); self::assertSame(CascadingStrategy::CASCADE, $metadata->getCascadingStrategy()); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/ChoiceTest.php b/src/Symfony/Component/Validator/Tests/Constraints/ChoiceTest.php index cbdaf3f8e8aa4..9c58dd10714d9 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/ChoiceTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/ChoiceTest.php @@ -14,7 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; use Symfony\Component\Validator\Tests\Fixtures\ConstraintChoiceWithPreset; class ChoiceTest extends TestCase @@ -29,7 +29,7 @@ public function testSetDefaultPropertyChoice() public function testAttributes() { $metadata = new ClassMetadata(ChoiceDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); /** @var Choice $aConstraint */ diff --git a/src/Symfony/Component/Validator/Tests/Constraints/CidrTest.php b/src/Symfony/Component/Validator/Tests/Constraints/CidrTest.php index 33ce5bdc73ec3..c9239de4d2a5b 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/CidrTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/CidrTest.php @@ -16,7 +16,7 @@ use Symfony\Component\Validator\Constraints\Ip; use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class CidrTest extends TestCase { @@ -123,7 +123,7 @@ public static function getValidMinMaxValues(): array public function testAttributes() { $metadata = new ClassMetadata(CidrDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/CountTest.php b/src/Symfony/Component/Validator/Tests/Constraints/CountTest.php index 1493cbf763f6f..42843e12df044 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/CountTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/CountTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Count; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class CountTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(CountDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/CountValidatorTestCase.php b/src/Symfony/Component/Validator/Tests/Constraints/CountValidatorTestCase.php index 104c90773264e..c52cd4e69d394 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/CountValidatorTestCase.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/CountValidatorTestCase.php @@ -283,7 +283,7 @@ public function testDefaultOption() $this->assertEquals(5, $constraint->max); } - public function testConstraintAnnotationDefaultOption() + public function testConstraintAttributeDefaultOption() { $constraint = new Count(['value' => 5, 'exactMessage' => 'message']); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/CountryTest.php b/src/Symfony/Component/Validator/Tests/Constraints/CountryTest.php index 19f5978d43710..4bd85933de15d 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/CountryTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/CountryTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Country; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class CountryTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(CountryDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/CssColorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/CssColorTest.php index ae34c3201d55f..09938760bf54b 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/CssColorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/CssColorTest.php @@ -14,7 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\CssColor; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; /** * @author Mathieu Santostefano @@ -24,7 +24,7 @@ final class CssColorTest extends TestCase public function testAttributes() { $metadata = new ClassMetadata(CssColorDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/CurrencyTest.php b/src/Symfony/Component/Validator/Tests/Constraints/CurrencyTest.php index 883e78b488503..6cfb6bfc3b448 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/CurrencyTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/CurrencyTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Currency; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class CurrencyTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(CurrencyDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$bConstraint] = $metadata->properties['b']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/DateTest.php b/src/Symfony/Component/Validator/Tests/Constraints/DateTest.php index 22b318ef30be3..83c75328e7e9d 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/DateTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/DateTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Date; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class DateTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(DateDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$bConstraint] = $metadata->properties['b']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/DateTimeTest.php b/src/Symfony/Component/Validator/Tests/Constraints/DateTimeTest.php index e93870f623722..172d1a29054f5 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/DateTimeTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/DateTimeTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\DateTime; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class DateTimeTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(DateTimeDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/DisableAutoMappingTest.php b/src/Symfony/Component/Validator/Tests/Constraints/DisableAutoMappingTest.php index 63b2aaf457358..e47bc6aeb839f 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/DisableAutoMappingTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/DisableAutoMappingTest.php @@ -16,7 +16,7 @@ use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Mapping\AutoMappingStrategy; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; /** * @author Kévin Dunglas @@ -34,7 +34,7 @@ public function testGroups() public function testDisableAutoMappingAttribute() { $metadata = new ClassMetadata(DisableAutoMappingDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertSame(AutoMappingStrategy::NONE, $metadata->getAutoMappingStrategy()); self::assertTrue($loader->loadClassMetadata($metadata)); self::assertSame(AutoMappingStrategy::DISABLED, $metadata->getAutoMappingStrategy()); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/DivisibleByTest.php b/src/Symfony/Component/Validator/Tests/Constraints/DivisibleByTest.php index 7388bc71733b9..bfe0a6cac5764 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/DivisibleByTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/DivisibleByTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\DivisibleBy; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class DivisibleByTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(DivisibleByDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/EmailTest.php b/src/Symfony/Component/Validator/Tests/Constraints/EmailTest.php index b181e61edec7c..b6f710ffc7ad2 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/EmailTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/EmailTest.php @@ -15,7 +15,7 @@ use Symfony\Component\Validator\Constraints\Email; use Symfony\Component\Validator\Exception\InvalidArgumentException; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class EmailTest extends TestCase { @@ -64,7 +64,7 @@ public function testInvalidNormalizerObjectThrowsException() public function testAttribute() { $metadata = new ClassMetadata(EmailDummy::class); - (new AnnotationLoader())->loadClassMetadata($metadata); + (new AttributeLoader())->loadClassMetadata($metadata); [$aConstraint] = $metadata->properties['a']->constraints; self::assertNull($aConstraint->mode); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/EnableAutoMappingTest.php b/src/Symfony/Component/Validator/Tests/Constraints/EnableAutoMappingTest.php index db09c8ad3e02d..2a5a944d31a6f 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/EnableAutoMappingTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/EnableAutoMappingTest.php @@ -16,7 +16,7 @@ use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Mapping\AutoMappingStrategy; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; /** * @author Kévin Dunglas @@ -34,7 +34,7 @@ public function testGroups() public function testDisableAutoMappingAttribute() { $metadata = new ClassMetadata(EnableAutoMappingDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertSame(AutoMappingStrategy::NONE, $metadata->getAutoMappingStrategy()); self::assertTrue($loader->loadClassMetadata($metadata)); self::assertSame(AutoMappingStrategy::ENABLED, $metadata->getAutoMappingStrategy()); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/EqualToTest.php b/src/Symfony/Component/Validator/Tests/Constraints/EqualToTest.php index d63be4af6ecd9..56e40dbddf307 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/EqualToTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/EqualToTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\EqualTo; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class EqualToTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(EqualToDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/ExpressionSyntaxTest.php b/src/Symfony/Component/Validator/Tests/Constraints/ExpressionSyntaxTest.php index 7f319f23d5ef1..3f77cace2c2ee 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/ExpressionSyntaxTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/ExpressionSyntaxTest.php @@ -15,7 +15,7 @@ use Symfony\Component\Validator\Constraints\ExpressionSyntax; use Symfony\Component\Validator\Constraints\ExpressionSyntaxValidator; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class ExpressionSyntaxTest extends TestCase { @@ -41,7 +41,7 @@ public static function provideServiceValidatedConstraints(): iterable yield 'named arguments' => [new ExpressionSyntax(service: 'my_service')]; $metadata = new ClassMetadata(ExpressionSyntaxDummy::class); - self::assertTrue((new AnnotationLoader())->loadClassMetadata($metadata)); + self::assertTrue((new AttributeLoader())->loadClassMetadata($metadata)); yield 'attribute' => [$metadata->properties['b']->constraints[0]]; } @@ -49,7 +49,7 @@ public static function provideServiceValidatedConstraints(): iterable public function testAttributes() { $metadata = new ClassMetadata(ExpressionSyntaxDummy::class); - self::assertTrue((new AnnotationLoader())->loadClassMetadata($metadata)); + self::assertTrue((new AttributeLoader())->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); self::assertNull($aConstraint->service); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/ExpressionTest.php b/src/Symfony/Component/Validator/Tests/Constraints/ExpressionTest.php index 08d4b5ffb2e85..89db330b99c55 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/ExpressionTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/ExpressionTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Expression; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class ExpressionTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(ExpressionDummy::class); - self::assertTrue((new AnnotationLoader())->loadClassMetadata($metadata)); + self::assertTrue((new AttributeLoader())->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); self::assertSame('value == "1"', $aConstraint->expression); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/ExpressionValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/ExpressionValidatorTest.php index f427604725a2f..132507c923af7 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/ExpressionValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/ExpressionValidatorTest.php @@ -14,6 +14,9 @@ use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\Validator\Constraints\Expression; use Symfony\Component\Validator\Constraints\ExpressionValidator; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Range; +use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; use Symfony\Component\Validator\Tests\Fixtures\NestedAttribute\Entity; use Symfony\Component\Validator\Tests\Fixtures\ToString; @@ -304,4 +307,81 @@ public function testViolationOnPass() ->setCode(Expression::EXPRESSION_FAILED_ERROR) ->assertRaised(); } + + public function testIsValidExpression() + { + $constraints = [new NotNull(), new Range(['min' => 2])]; + + $constraint = new Expression( + ['expression' => 'is_valid(this.data, a)', 'values' => ['a' => $constraints]] + ); + + $object = new Entity(); + $object->data = 7; + + $this->setObject($object); + + $this->expectValidateValue(0, $object->data, $constraints); + + $this->validator->validate($object, $constraint); + + $this->assertNoViolation(); + } + + public function testIsValidExpressionInvalid() + { + $constraints = [new Range(['min' => 2, 'max' => 5])]; + + $constraint = new Expression( + ['expression' => 'is_valid(this.data, a)', 'values' => ['a' => $constraints]] + ); + + $object = new Entity(); + $object->data = 7; + + $this->setObject($object); + + $this->expectFailingValueValidation( + 0, + 7, + $constraints, + null, + new ConstraintViolation('error_range', '', [], '', '', 7, null, 'range') + ); + + $this->validator->validate($object, $constraint); + + $this->assertCount(2, $this->context->getViolations()); + } + + /** + * @dataProvider provideCompileIsValid + */ + public function testCompileIsValid(string $expression, array $names, string $expected) + { + $provider = new ExpressionValidator(); + + $expressionLanguage = new ExpressionLanguage(); + $expressionLanguage->registerProvider($provider); + + $result = $expressionLanguage->compile($expression, $names); + + $this->assertSame($expected, $result); + } + + public static function provideCompileIsValid(): array + { + return [ + [ + 'is_valid("foo", constraints)', + ['constraints'], + '0 === $context->getValidator()->inContext($context)->validate("foo", $constraints)->getViolations()->count()', + ], + [ + 'is_valid(this.data, constraints, groups)', + ['this', 'constraints', 'groups'], + '0 === $context->getValidator()->inContext($context)->validate($this->data, $constraints, $groups)->getViolations()->count()', + ], + ]; + } } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/FileTest.php b/src/Symfony/Component/Validator/Tests/Constraints/FileTest.php index c9a36d020f226..81a228b64d751 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/FileTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/FileTest.php @@ -15,7 +15,7 @@ use Symfony\Component\Validator\Constraints\File; use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class FileTest extends TestCase { @@ -144,7 +144,7 @@ public static function provideFormats() public function testAttributes() { $metadata = new ClassMetadata(FileDummy::class); - self::assertTrue((new AnnotationLoader())->loadClassMetadata($metadata)); + self::assertTrue((new AttributeLoader())->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); self::assertNull($aConstraint->maxSize); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanOrEqualTest.php b/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanOrEqualTest.php index f997d3821c2a2..270ac529652cc 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanOrEqualTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanOrEqualTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\GreaterThanOrEqual; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class GreaterThanOrEqualTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(GreaterThanOrEqualDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanTest.php b/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanTest.php index 848db00204b93..8dd797cf47fd3 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\GreaterThan; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class GreaterThanTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(GreaterThanDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/HostnameTest.php b/src/Symfony/Component/Validator/Tests/Constraints/HostnameTest.php index ed682747ba67f..f6189c8d9cf7d 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/HostnameTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/HostnameTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Hostname; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class HostnameTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(HostnameDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/IbanValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/IbanValidatorTest.php index ef8f208753f24..212d5329f44af 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/IbanValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/IbanValidatorTest.php @@ -14,7 +14,7 @@ use Symfony\Component\Validator\Constraints\Iban; use Symfony\Component\Validator\Constraints\IbanValidator; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; class IbanValidatorTest extends ConstraintValidatorTestCase @@ -439,7 +439,7 @@ public function testIbansWithInvalidCountryCode($iban) public function testLoadFromAttribute() { $classMetadata = new ClassMetadata(IbanDummy::class); - (new AnnotationLoader())->loadClassMetadata($classMetadata); + (new AttributeLoader())->loadClassMetadata($classMetadata); [$constraint] = $classMetadata->properties['iban']->constraints; diff --git a/src/Symfony/Component/Validator/Tests/Constraints/IdenticalToTest.php b/src/Symfony/Component/Validator/Tests/Constraints/IdenticalToTest.php index fa550497d1ed7..6eb7044c6b1f4 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/IdenticalToTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/IdenticalToTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\IdenticalTo; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class IdenticalToTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(IdenticalToDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/ImageTest.php b/src/Symfony/Component/Validator/Tests/Constraints/ImageTest.php index 9e6d57144c6b1..df5b14c073548 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/ImageTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/ImageTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Image; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class ImageTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(ImageDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/IpTest.php b/src/Symfony/Component/Validator/Tests/Constraints/IpTest.php index 0854e53ebc38c..7f391153f3a69 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/IpTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/IpTest.php @@ -15,7 +15,7 @@ use Symfony\Component\Validator\Constraints\Ip; use Symfony\Component\Validator\Exception\InvalidArgumentException; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; /** * @author Renan Taranto @@ -46,7 +46,7 @@ public function testInvalidNormalizerObjectThrowsException() public function testAttributes() { $metadata = new ClassMetadata(IpDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/IsbnTest.php b/src/Symfony/Component/Validator/Tests/Constraints/IsbnTest.php index 305d3fec0e80d..d11088376ed5b 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/IsbnTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/IsbnTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Isbn; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class IsbnTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(IsbnDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/IsinTest.php b/src/Symfony/Component/Validator/Tests/Constraints/IsinTest.php index 89aaecc764e9e..05d24a32d4870 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/IsinTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/IsinTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Isin; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class IsinTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(IsinDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$bConstraint] = $metadata->properties['b']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/IssnTest.php b/src/Symfony/Component/Validator/Tests/Constraints/IssnTest.php index cd4085bf0f43f..c21648bd63a51 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/IssnTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/IssnTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Issn; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class IssnTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(IssnDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/JsonTest.php b/src/Symfony/Component/Validator/Tests/Constraints/JsonTest.php index 15cd3c6942ae7..17208425f3881 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/JsonTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/JsonTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Json; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class JsonTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(JsonDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$bConstraint] = $metadata->properties['b']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/LanguageTest.php b/src/Symfony/Component/Validator/Tests/Constraints/LanguageTest.php index ebeaa2e76f913..fe0c5e711b9b3 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/LanguageTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/LanguageTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Language; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class LanguageTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(LanguageDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/LengthTest.php b/src/Symfony/Component/Validator/Tests/Constraints/LengthTest.php index c3b8606c3d994..03bd37674922e 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/LengthTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/LengthTest.php @@ -15,7 +15,7 @@ use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Exception\InvalidArgumentException; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; /** * @author Renan Taranto @@ -70,7 +70,7 @@ public function testConstraintDefaultOption() self::assertEquals(5, $constraint->max); } - public function testConstraintAnnotationDefaultOption() + public function testConstraintAttributeDefaultOption() { $constraint = new Length(['value' => 5, 'exactMessage' => 'message']); @@ -82,7 +82,7 @@ public function testConstraintAnnotationDefaultOption() public function testAttributes() { $metadata = new ClassMetadata(LengthDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/LessThanOrEqualTest.php b/src/Symfony/Component/Validator/Tests/Constraints/LessThanOrEqualTest.php index 77a12e1e95ee1..c738e812394a6 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/LessThanOrEqualTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/LessThanOrEqualTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\LessThanOrEqual; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class LessThanOrEqualTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(LessThanOrEqualDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/LessThanTest.php b/src/Symfony/Component/Validator/Tests/Constraints/LessThanTest.php index 025c5069898af..47dadaf6a5bb4 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/LessThanTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/LessThanTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\LessThan; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class LessThanTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(LessThanDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/LocaleTest.php b/src/Symfony/Component/Validator/Tests/Constraints/LocaleTest.php index d2e048a4d3f5f..b6beb13ddb9bc 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/LocaleTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/LocaleTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Locale; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class LocaleTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(LocaleDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/LuhnTest.php b/src/Symfony/Component/Validator/Tests/Constraints/LuhnTest.php index 4f381b3f62495..f144785d27f93 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/LuhnTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/LuhnTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Luhn; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class LuhnTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(LuhnDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$bConstraint] = $metadata->properties['b']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/NegativeOrZeroTest.php b/src/Symfony/Component/Validator/Tests/Constraints/NegativeOrZeroTest.php index 625dc436c8490..109713f07980f 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/NegativeOrZeroTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/NegativeOrZeroTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\NegativeOrZero; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class NegativeOrZeroTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(NegativeOrZeroDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/NegativeTest.php b/src/Symfony/Component/Validator/Tests/Constraints/NegativeTest.php index a4b8f6a53c1ee..4a3c67da99a74 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/NegativeTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/NegativeTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Negative; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class NegativeTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(NegativeDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/NotBlankTest.php b/src/Symfony/Component/Validator/Tests/Constraints/NotBlankTest.php index d6d03c036d5c0..77435a37a3ca7 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/NotBlankTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/NotBlankTest.php @@ -15,7 +15,7 @@ use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Exception\InvalidArgumentException; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; /** * @author Renan Taranto @@ -32,7 +32,7 @@ public function testNormalizerCanBeSet() public function testAttributes() { $metadata = new ClassMetadata(NotBlankDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/NotCompromisedPasswordTest.php b/src/Symfony/Component/Validator/Tests/Constraints/NotCompromisedPasswordTest.php index 622d86dbfb759..0ca6dcb2a232c 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/NotCompromisedPasswordTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/NotCompromisedPasswordTest.php @@ -14,7 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\NotCompromisedPassword; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; /** * @author Kévin Dunglas @@ -31,7 +31,7 @@ public function testDefaultValues() public function testAttributes() { $metadata = new ClassMetadata(NotCompromisedPasswordDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/NotEqualToTest.php b/src/Symfony/Component/Validator/Tests/Constraints/NotEqualToTest.php index 0da3c5874b248..ff36b95cbb4d4 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/NotEqualToTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/NotEqualToTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\NotEqualTo; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class NotEqualToTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(NotEqualToDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/NotIdenticalToTest.php b/src/Symfony/Component/Validator/Tests/Constraints/NotIdenticalToTest.php index 7d561e60c42e9..de34f05ebd0ee 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/NotIdenticalToTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/NotIdenticalToTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\NotIdenticalTo; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class NotIdenticalToTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(NotIdenticalToDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/PositiveOrZeroTest.php b/src/Symfony/Component/Validator/Tests/Constraints/PositiveOrZeroTest.php index e68b5a7fc4d30..b13c803aa0079 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/PositiveOrZeroTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/PositiveOrZeroTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\PositiveOrZero; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class PositiveOrZeroTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(PositiveOrZeroDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/PositiveTest.php b/src/Symfony/Component/Validator/Tests/Constraints/PositiveTest.php index 4a56243fc9e3f..f06e616538a6f 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/PositiveTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/PositiveTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Positive; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class PositiveTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(PositiveDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/RegexTest.php b/src/Symfony/Component/Validator/Tests/Constraints/RegexTest.php index 74fbec23b9333..ba24fb0cb2073 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/RegexTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/RegexTest.php @@ -15,7 +15,7 @@ use Symfony\Component\Validator\Constraints\Regex; use Symfony\Component\Validator\Exception\InvalidArgumentException; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; /** * @author Bernhard Schussek @@ -113,7 +113,7 @@ public function testInvalidNormalizerObjectThrowsException() public function testAttributes() { $metadata = new ClassMetadata(RegexDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/TimeTest.php b/src/Symfony/Component/Validator/Tests/Constraints/TimeTest.php index ba63e3187ec5e..2e3132039e132 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/TimeTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/TimeTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Time; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class TimeTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(TimeDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$bConstraint] = $metadata->properties['b']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/TimezoneTest.php b/src/Symfony/Component/Validator/Tests/Constraints/TimezoneTest.php index 90291c5763944..42a38a7110101 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/TimezoneTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/TimezoneTest.php @@ -15,7 +15,7 @@ use Symfony\Component\Validator\Constraints\Timezone; use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; /** * @author Javier Spagnoletti @@ -69,7 +69,7 @@ public static function provideInvalidZones(): iterable public function testAttributes() { $metadata = new ClassMetadata(TimezoneDummy::class); - self::assertTrue((new AnnotationLoader())->loadClassMetadata($metadata)); + self::assertTrue((new AttributeLoader())->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); self::assertSame(\DateTimeZone::ALL, $aConstraint->zone); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/TraverseTest.php b/src/Symfony/Component/Validator/Tests/Constraints/TraverseTest.php index efa42005bd126..a9729bfcfb2b8 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/TraverseTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/TraverseTest.php @@ -14,7 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Traverse; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; use Symfony\Component\Validator\Mapping\TraversalStrategy; class TraverseTest extends TestCase @@ -22,7 +22,7 @@ class TraverseTest extends TestCase public function testPositiveAttributes() { $metadata = new ClassMetadata(TraverseDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); self::assertSame(TraversalStrategy::TRAVERSE, $metadata->getTraversalStrategy()); } @@ -30,7 +30,7 @@ public function testPositiveAttributes() public function testNegativeAttribute() { $metadata = new ClassMetadata(DoNotTraverseMe::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); self::assertSame(TraversalStrategy::NONE, $metadata->getTraversalStrategy()); } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/TypeTest.php b/src/Symfony/Component/Validator/Tests/Constraints/TypeTest.php index f081110a7f382..2c018cdbf3d1f 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/TypeTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/TypeTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Type; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class TypeTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(TypeDummy::class); - self::assertTrue((new AnnotationLoader())->loadClassMetadata($metadata)); + self::assertTrue((new AttributeLoader())->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); self::assertSame('integer', $aConstraint->type); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/UlidTest.php b/src/Symfony/Component/Validator/Tests/Constraints/UlidTest.php index 74fcc30083f9c..14046e37a0ac5 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/UlidTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/UlidTest.php @@ -14,14 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Ulid; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class UlidTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(UlidDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$bConstraint] = $metadata->properties['b']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/UniqueTest.php b/src/Symfony/Component/Validator/Tests/Constraints/UniqueTest.php index c8327e3c2fc9a..7d882a9c3cfb3 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/UniqueTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/UniqueTest.php @@ -15,14 +15,14 @@ use Symfony\Component\Validator\Constraints\Unique; use Symfony\Component\Validator\Exception\InvalidArgumentException; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; class UniqueTest extends TestCase { public function testAttributes() { $metadata = new ClassMetadata(UniqueDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$bConstraint] = $metadata->properties['b']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/UrlTest.php b/src/Symfony/Component/Validator/Tests/Constraints/UrlTest.php index 8196a6284b6d3..a7b0539045a6c 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/UrlTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/UrlTest.php @@ -15,7 +15,7 @@ use Symfony\Component\Validator\Constraints\Url; use Symfony\Component\Validator\Exception\InvalidArgumentException; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; /** * @author Renan Taranto @@ -46,7 +46,7 @@ public function testInvalidNormalizerObjectThrowsException() public function testAttributes() { $metadata = new ClassMetadata(UrlDummy::class); - self::assertTrue((new AnnotationLoader())->loadClassMetadata($metadata)); + self::assertTrue((new AttributeLoader())->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); self::assertSame(['http', 'https'], $aConstraint->protocols); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/UuidTest.php b/src/Symfony/Component/Validator/Tests/Constraints/UuidTest.php index ecb7aee326a14..3da8b81336719 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/UuidTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/UuidTest.php @@ -15,7 +15,7 @@ use Symfony\Component\Validator\Constraints\Uuid; use Symfony\Component\Validator\Exception\InvalidArgumentException; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; /** * @author Renan Taranto @@ -46,7 +46,7 @@ public function testInvalidNormalizerObjectThrowsException() public function testAttributes() { $metadata = new ClassMetadata(UuidDummy::class); - self::assertTrue((new AnnotationLoader())->loadClassMetadata($metadata)); + self::assertTrue((new AttributeLoader())->loadClassMetadata($metadata)); [$aConstraint] = $metadata->properties['a']->getConstraints(); self::assertSame(Uuid::ALL_VERSIONS, $aConstraint->versions); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/ValidTest.php b/src/Symfony/Component/Validator/Tests/Constraints/ValidTest.php index e84dbd71d8fd6..4c3946f97d0f9 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/ValidTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/ValidTest.php @@ -14,7 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Valid; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; /** * @author Bernhard Schussek @@ -38,7 +38,7 @@ public function testGroupsAreNullByDefault() public function testAttributes() { $metadata = new ClassMetaData(ValidDummy::class); - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); self::assertTrue($loader->loadClassMetadata($metadata)); [$bConstraint] = $metadata->properties['b']->getConstraints(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/ValidValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/ValidValidatorTest.php index 8c625949feb13..a3765172b1d8e 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/ValidValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/ValidValidatorTest.php @@ -20,7 +20,7 @@ class ValidValidatorTest extends TestCase public function testPropertyPathsArePassedToNestedContexts() { $validatorBuilder = new ValidatorBuilder(); - $validator = $validatorBuilder->enableAnnotationMapping()->getValidator(); + $validator = $validatorBuilder->enableAttributeMapping()->getValidator(); $violations = $validator->validate(new Foo(), null, ['nested']); @@ -31,7 +31,7 @@ public function testPropertyPathsArePassedToNestedContexts() public function testNullValues() { $validatorBuilder = new ValidatorBuilder(); - $validator = $validatorBuilder->enableAnnotationMapping()->getValidator(); + $validator = $validatorBuilder->enableAttributeMapping()->getValidator(); $foo = new Foo(); $foo->fooBar = null; diff --git a/src/Symfony/Component/Validator/Tests/Constraints/WhenTest.php b/src/Symfony/Component/Validator/Tests/Constraints/WhenTest.php index db7d615b5fa82..12d2bd146dda1 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/WhenTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/WhenTest.php @@ -19,7 +19,7 @@ use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Exception\MissingOptionsException; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; use Symfony\Component\Validator\Tests\Constraints\Fixtures\WhenTestWithAttributes; final class WhenTest extends TestCase @@ -43,7 +43,7 @@ public function testNonConstraintsAreRejected() public function testAttributes() { - $loader = new AnnotationLoader(); + $loader = new AttributeLoader(); $metadata = new ClassMetadata(WhenTestWithAttributes::class); self::assertTrue($loader->loadClassMetadata($metadata)); diff --git a/src/Symfony/Component/Validator/Tests/Mapping/Loader/AnnotationLoaderTest.php b/src/Symfony/Component/Validator/Tests/Mapping/Loader/AttributeLoaderTest.php similarity index 92% rename from src/Symfony/Component/Validator/Tests/Mapping/Loader/AnnotationLoaderTest.php rename to src/Symfony/Component/Validator/Tests/Mapping/Loader/AttributeLoaderTest.php index 474064274bb37..f9cb0da9b2d3c 100644 --- a/src/Symfony/Component/Validator/Tests/Mapping/Loader/AnnotationLoaderTest.php +++ b/src/Symfony/Component/Validator/Tests/Mapping/Loader/AttributeLoaderTest.php @@ -29,14 +29,14 @@ use Symfony\Component\Validator\Constraints\Type; use Symfony\Component\Validator\Constraints\Valid; use Symfony\Component\Validator\Mapping\ClassMetadata; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; use Symfony\Component\Validator\Tests\Fixtures\ConstraintA; -class AnnotationLoaderTest extends TestCase +class AttributeLoaderTest extends TestCase { public function testLoadClassMetadataReturnsTrueIfSuccessful() { - $loader = $this->createAnnotationLoader(); + $loader = $this->createAttributeLoader(); $metadata = new ClassMetadata($this->getFixtureNamespace().'\Entity'); $this->assertTrue($loader->loadClassMetadata($metadata)); @@ -44,7 +44,7 @@ public function testLoadClassMetadataReturnsTrueIfSuccessful() public function testLoadClassMetadataReturnsFalseIfNotSuccessful() { - $loader = $this->createAnnotationLoader(); + $loader = $this->createAttributeLoader(); $metadata = new ClassMetadata('\stdClass'); $this->assertFalse($loader->loadClassMetadata($metadata)); @@ -52,7 +52,7 @@ public function testLoadClassMetadataReturnsFalseIfNotSuccessful() public function testLoadClassMetadata() { - $loader = $this->createAnnotationLoader(); + $loader = $this->createAttributeLoader(); $namespace = $this->getFixtureNamespace(); $metadata = new ClassMetadata($namespace.'\Entity'); @@ -104,11 +104,11 @@ public function testLoadClassMetadata() } /** - * Test MetaData merge with parent annotation. + * Test MetaData merge with parent attribute. */ public function testLoadParentClassMetadata() { - $loader = $this->createAnnotationLoader(); + $loader = $this->createAttributeLoader(); $namespace = $this->getFixtureNamespace(); // Load Parent MetaData @@ -123,11 +123,11 @@ public function testLoadParentClassMetadata() } /** - * Test MetaData merge with parent annotation. + * Test MetaData merge with parent attribute. */ public function testLoadClassMetadataAndMerge() { - $loader = $this->createAnnotationLoader(); + $loader = $this->createAttributeLoader(); $namespace = $this->getFixtureNamespace(); // Load Parent MetaData @@ -195,9 +195,9 @@ public function testLoadClassMetadataAndMerge() $this->assertInstanceOf(NotNull::class, $otherMetadata[1]->getConstraints()[0]); } - public function testLoadGroupSequenceProviderAnnotation() + public function testLoadGroupSequenceProviderAttribute() { - $loader = $this->createAnnotationLoader(); + $loader = $this->createAttributeLoader(); $namespace = $this->getFixtureNamespace(); $metadata = new ClassMetadata($namespace.'\GroupSequenceProviderEntity'); @@ -210,9 +210,9 @@ public function testLoadGroupSequenceProviderAnnotation() $this->assertEquals($expected, $metadata); } - protected function createAnnotationLoader(): AnnotationLoader + protected function createAttributeLoader(): AttributeLoader { - return new AnnotationLoader(); + return new AttributeLoader(); } protected function getFixtureNamespace(): string diff --git a/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php b/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php index 95cdee8c7dde0..d2f205d5177ac 100644 --- a/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php +++ b/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php @@ -92,7 +92,7 @@ public function testLoadClassMetadata() $propertyInfoLoader = new PropertyInfoLoader($propertyInfoStub, $propertyInfoStub, $propertyInfoStub, '{.*}'); $validator = Validation::createValidatorBuilder() - ->enableAnnotationMapping() + ->enableAttributeMapping() ->addLoader($propertyInfoLoader) ->getValidator() ; @@ -230,7 +230,7 @@ public function testClassNoAutoMapping() $propertyInfoLoader = new PropertyInfoLoader($propertyInfoStub, $propertyInfoStub, $propertyInfoStub, '{.*}'); $validator = Validation::createValidatorBuilder() - ->enableAnnotationMapping() + ->enableAttributeMapping() ->addLoader($propertyInfoLoader) ->getValidator() ; diff --git a/src/Symfony/Component/Validator/Tests/ValidatorBuilderTest.php b/src/Symfony/Component/Validator/Tests/ValidatorBuilderTest.php index 93a22257d4485..c57a507e25579 100644 --- a/src/Symfony/Component/Validator/Tests/ValidatorBuilderTest.php +++ b/src/Symfony/Component/Validator/Tests/ValidatorBuilderTest.php @@ -70,9 +70,9 @@ public function testAddMethodMappings() $this->assertSame($this->builder, $this->builder->addMethodMappings([])); } - public function testDisableAnnotationMapping() + public function testDisableAttributeMapping() { - $this->assertSame($this->builder, $this->builder->disableAnnotationMapping()); + $this->assertSame($this->builder, $this->builder->disableAttributeMapping()); } public function testSetMappingCache() diff --git a/src/Symfony/Component/Validator/ValidatorBuilder.php b/src/Symfony/Component/Validator/ValidatorBuilder.php index f0184a58be302..44f7161fac1e3 100644 --- a/src/Symfony/Component/Validator/ValidatorBuilder.php +++ b/src/Symfony/Component/Validator/ValidatorBuilder.php @@ -16,7 +16,7 @@ use Symfony\Component\Validator\Exception\ValidatorException; use Symfony\Component\Validator\Mapping\Factory\LazyLoadingMetadataFactory; use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; -use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; use Symfony\Component\Validator\Mapping\Loader\LoaderChain; use Symfony\Component\Validator\Mapping\Loader\LoaderInterface; use Symfony\Component\Validator\Mapping\Loader\StaticMethodLoader; @@ -43,7 +43,7 @@ class ValidatorBuilder private array $xmlMappings = []; private array $yamlMappings = []; private array $methodMappings = []; - private bool $enableAnnotationMapping = false; + private bool $enableAttributeMapping = false; private ?MetadataFactoryInterface $metadataFactory = null; private ConstraintValidatorFactoryInterface $validatorFactory; private ?CacheItemPoolInterface $mappingCache = null; @@ -181,29 +181,29 @@ public function addMethodMappings(array $methodNames): static } /** - * Enables annotation and attribute based constraint mapping. + * Enables attribute-based constraint mapping. * * @return $this */ - public function enableAnnotationMapping(): static + public function enableAttributeMapping(): static { if (null !== $this->metadataFactory) { - throw new ValidatorException('You cannot enable annotation mapping after setting a custom metadata factory. Configure your metadata factory instead.'); + throw new ValidatorException('You cannot enable attribute mapping after setting a custom metadata factory. Configure your metadata factory instead.'); } - $this->enableAnnotationMapping = true; + $this->enableAttributeMapping = true; return $this; } /** - * Disables annotation and attribute based constraint mapping. + * Disables attribute-based constraint mapping. * * @return $this */ - public function disableAnnotationMapping(): static + public function disableAttributeMapping(): static { - $this->enableAnnotationMapping = false; + $this->enableAttributeMapping = false; return $this; } @@ -215,7 +215,7 @@ public function disableAnnotationMapping(): static */ public function setMetadataFactory(MetadataFactoryInterface $metadataFactory): static { - if (\count($this->xmlMappings) > 0 || \count($this->yamlMappings) > 0 || \count($this->methodMappings) > 0 || $this->enableAnnotationMapping) { + if (\count($this->xmlMappings) > 0 || \count($this->yamlMappings) > 0 || \count($this->methodMappings) > 0 || $this->enableAttributeMapping) { throw new ValidatorException('You cannot set a custom metadata factory after adding custom mappings. You should do either of both.'); } @@ -309,8 +309,8 @@ public function getLoaders(): array $loaders[] = new StaticMethodLoader($methodName); } - if ($this->enableAnnotationMapping) { - $loaders[] = new AnnotationLoader(); + if ($this->enableAttributeMapping) { + $loaders[] = new AttributeLoader(); } return array_merge($loaders, $this->loaders); diff --git a/src/Symfony/Component/Validator/composer.json b/src/Symfony/Component/Validator/composer.json index a9d5cced1e647..8e050441c4019 100644 --- a/src/Symfony/Component/Validator/composer.json +++ b/src/Symfony/Component/Validator/composer.json @@ -43,6 +43,7 @@ "conflict": { "doctrine/lexer": "<1.1", "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<7.0", "symfony/expression-language": "<6.4", "symfony/http-kernel": "<6.4", "symfony/intl": "<6.4", diff --git a/src/Symfony/Component/VarDumper/Dumper/CliDumper.php b/src/Symfony/Component/VarDumper/Dumper/CliDumper.php index 946468ef38666..c66cdcd412e9d 100644 --- a/src/Symfony/Component/VarDumper/Dumper/CliDumper.php +++ b/src/Symfony/Component/VarDumper/Dumper/CliDumper.php @@ -124,6 +124,7 @@ public function setDisplayOptions(array $displayOptions): void public function dumpScalar(Cursor $cursor, string $type, string|int|float|bool|null $value): void { $this->dumpKey($cursor); + $this->collapseNextHash = $this->expandNextHash = false; $style = 'const'; $attr = $cursor->attr; @@ -184,6 +185,7 @@ public function dumpScalar(Cursor $cursor, string $type, string|int|float|bool|n public function dumpString(Cursor $cursor, string $str, bool $bin, int $cut): void { $this->dumpKey($cursor); + $this->collapseNextHash = $this->expandNextHash = false; $attr = $cursor->attr; if ($bin) { @@ -274,6 +276,7 @@ public function enterHash(Cursor $cursor, int $type, string|int|null $class, boo $this->colors ??= $this->supportsColors(); $this->dumpKey($cursor); + $this->expandNextHash = false; $attr = $cursor->attr; if ($this->collapseNextHash) { diff --git a/src/Symfony/Component/VarDumper/Tests/Command/Descriptor/CliDescriptorTest.php b/src/Symfony/Component/VarDumper/Tests/Command/Descriptor/CliDescriptorTest.php index e35722fb2c8b2..5a24f1c17c508 100644 --- a/src/Symfony/Component/VarDumper/Tests/Command/Descriptor/CliDescriptorTest.php +++ b/src/Symfony/Component/VarDumper/Tests/Command/Descriptor/CliDescriptorTest.php @@ -12,7 +12,6 @@ namespace Symfony\Component\VarDumper\Tests\Command\Descriptor; use PHPUnit\Framework\TestCase; -use Symfony\Component\Console\Formatter\OutputFormatterStyle; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\VarDumper\Cloner\Data; use Symfony\Component\VarDumper\Command\Descriptor\CliDescriptor; @@ -84,20 +83,7 @@ public static function provideContext() 'file_link' => 'phpstorm://open?file=/Users/ogi/symfony/src/Symfony/Component/VarDumper/Tests/Command/Descriptor/CliDescriptorTest.php&line=30', ], ], - method_exists(OutputFormatterStyle::class, 'setHref') ? - << [ - [ - 'source' => [ - 'name' => 'CliDescriptorTest.php', - 'line' => 30, - 'file_relative' => 'src/Symfony/Component/VarDumper/Tests/Command/Descriptor/CliDescriptorTest.php', - 'file_link' => 'phpstorm://open?file=/Users/ogi/symfony/src/Symfony/Component/VarDumper/Tests/Command/Descriptor/CliDescriptorTest.php&line=30', - ], + yield 'source with hyperlink' => [ + [ + 'source' => [ + 'name' => 'CliDescriptorTest.php', + 'line' => 30, + 'file_relative' => 'src/Symfony/Component/VarDumper/Tests/Command/Descriptor/CliDescriptorTest.php', + 'file_link' => 'phpstorm://open?file=/Users/ogi/symfony/src/Symfony/Component/VarDumper/Tests/Command/Descriptor/CliDescriptorTest.php&line=30', ], - << [ [ diff --git a/src/Symfony/Component/VarDumper/Tests/Dumper/CliDumperTest.php b/src/Symfony/Component/VarDumper/Tests/Dumper/CliDumperTest.php index 3321dc137e3c3..37b70800866be 100644 --- a/src/Symfony/Component/VarDumper/Tests/Dumper/CliDumperTest.php +++ b/src/Symfony/Component/VarDumper/Tests/Dumper/CliDumperTest.php @@ -13,6 +13,8 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\VarDumper\Caster\CutStub; +use Symfony\Component\VarDumper\Cloner\Data; +use Symfony\Component\VarDumper\Cloner\Stub; use Symfony\Component\VarDumper\Cloner\VarCloner; use Symfony\Component\VarDumper\Dumper\AbstractDumper; use Symfony\Component\VarDumper\Dumper\CliDumper; @@ -455,4 +457,46 @@ public function testDumpArrayWithColor($value, $flags, $expectedOut) $this->assertSame($expectedOut, $out); } + + public function testCollapse() + { + if ('\\' === \DIRECTORY_SEPARATOR) { + $this->markTestSkipped('This test cannot be run on Windows.'); + } + + $stub = new Stub(); + $stub->type = Stub::TYPE_OBJECT; + $stub->class = 'stdClass'; + $stub->position = 1; + + $data = new Data([ + [ + $stub, + ], + [ + "\0~collapse=1\0foo" => 123, + "\0+\0bar" => [1 => 2], + ], + [ + 'bar' => 123, + ] + ]); + + $dumper = new CliDumper(); + $dump = $dumper->dump($data, true); + + $this->assertSame( + <<<'EOTXT' +{ + foo: 123 + +"bar": array:1 [ + "bar" => 123 + ] +} + +EOTXT + , + $dump + ); + } } diff --git a/src/Symfony/Component/VarDumper/composer.json b/src/Symfony/Component/VarDumper/composer.json index 26fe8d54c80b6..cbc671760874d 100644 --- a/src/Symfony/Component/VarDumper/composer.json +++ b/src/Symfony/Component/VarDumper/composer.json @@ -25,7 +25,7 @@ "symfony/http-kernel": "^6.4|^7.0", "symfony/process": "^6.4|^7.0", "symfony/uid": "^6.4|^7.0", - "twig/twig": "^2.13|^3.0.4" + "twig/twig": "^3.0.4" }, "conflict": { "symfony/console": "<6.4" diff --git a/src/Symfony/Component/Workflow/CHANGELOG.md b/src/Symfony/Component/Workflow/CHANGELOG.md index a2720702f98dd..59a15f60081eb 100644 --- a/src/Symfony/Component/Workflow/CHANGELOG.md +++ b/src/Symfony/Component/Workflow/CHANGELOG.md @@ -5,6 +5,8 @@ CHANGELOG --- * Require explicit argument when calling `Definition::setInitialPlaces()` + * `GuardEvent::getContext()` method has been removed. Method was not supposed to be called within guard event listeners as it always returned an empty array anyway. + * Remove `GuardEvent::getContext()` method without replacement 6.4 --- @@ -15,6 +17,7 @@ CHANGELOG * Add a profiler * Add support for multiline descriptions in PlantUML diagrams * Add PHP attributes to register listeners and guards + * Deprecate `GuardEvent::getContext()` method that will be removed in 7.0 6.2 --- diff --git a/src/Symfony/Component/Workflow/Event/AnnounceEvent.php b/src/Symfony/Component/Workflow/Event/AnnounceEvent.php index 7d3d7409a11fe..ff0cfe59ac44f 100644 --- a/src/Symfony/Component/Workflow/Event/AnnounceEvent.php +++ b/src/Symfony/Component/Workflow/Event/AnnounceEvent.php @@ -11,6 +11,18 @@ namespace Symfony\Component\Workflow\Event; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\WorkflowInterface; + final class AnnounceEvent extends Event { + use HasContextTrait; + + public function __construct(object $subject, Marking $marking, Transition $transition = null, WorkflowInterface $workflow = null, array $context = []) + { + parent::__construct($subject, $marking, $transition, $workflow); + + $this->context = $context; + } } diff --git a/src/Symfony/Component/Workflow/Event/CompletedEvent.php b/src/Symfony/Component/Workflow/Event/CompletedEvent.php index 883390e958f43..9643d42fd2dd1 100644 --- a/src/Symfony/Component/Workflow/Event/CompletedEvent.php +++ b/src/Symfony/Component/Workflow/Event/CompletedEvent.php @@ -11,6 +11,18 @@ namespace Symfony\Component\Workflow\Event; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\WorkflowInterface; + final class CompletedEvent extends Event { + use HasContextTrait; + + public function __construct(object $subject, Marking $marking, Transition $transition = null, WorkflowInterface $workflow = null, array $context = []) + { + parent::__construct($subject, $marking, $transition, $workflow); + + $this->context = $context; + } } diff --git a/src/Symfony/Component/Workflow/Event/EnterEvent.php b/src/Symfony/Component/Workflow/Event/EnterEvent.php index 3296f29da9a6c..3a64cfa391038 100644 --- a/src/Symfony/Component/Workflow/Event/EnterEvent.php +++ b/src/Symfony/Component/Workflow/Event/EnterEvent.php @@ -11,6 +11,18 @@ namespace Symfony\Component\Workflow\Event; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\WorkflowInterface; + final class EnterEvent extends Event { + use HasContextTrait; + + public function __construct(object $subject, Marking $marking, Transition $transition = null, WorkflowInterface $workflow = null, array $context = []) + { + parent::__construct($subject, $marking, $transition, $workflow); + + $this->context = $context; + } } diff --git a/src/Symfony/Component/Workflow/Event/EnteredEvent.php b/src/Symfony/Component/Workflow/Event/EnteredEvent.php index ea3624b425cad..041324287e054 100644 --- a/src/Symfony/Component/Workflow/Event/EnteredEvent.php +++ b/src/Symfony/Component/Workflow/Event/EnteredEvent.php @@ -11,6 +11,18 @@ namespace Symfony\Component\Workflow\Event; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\WorkflowInterface; + final class EnteredEvent extends Event { + use HasContextTrait; + + public function __construct(object $subject, Marking $marking, Transition $transition = null, WorkflowInterface $workflow = null, array $context = []) + { + parent::__construct($subject, $marking, $transition, $workflow); + + $this->context = $context; + } } diff --git a/src/Symfony/Component/Workflow/Event/Event.php b/src/Symfony/Component/Workflow/Event/Event.php index 7bbdad25fad6d..66eada47b6ecb 100644 --- a/src/Symfony/Component/Workflow/Event/Event.php +++ b/src/Symfony/Component/Workflow/Event/Event.php @@ -23,20 +23,17 @@ */ class Event extends BaseEvent { - protected array $context; - private object $subject; private Marking $marking; private ?Transition $transition; private ?WorkflowInterface $workflow; - public function __construct(object $subject, Marking $marking, Transition $transition = null, WorkflowInterface $workflow = null, array $context = []) + public function __construct(object $subject, Marking $marking, Transition $transition = null, WorkflowInterface $workflow = null) { $this->subject = $subject; $this->marking = $marking; $this->transition = $transition; $this->workflow = $workflow; - $this->context = $context; } public function getMarking(): Marking @@ -68,9 +65,4 @@ public function getMetadata(string $key, string|Transition|null $subject): mixed { return $this->workflow->getMetadataStore()->getMetadata($key, $subject); } - - public function getContext(): array - { - return $this->context; - } } diff --git a/src/Symfony/Component/Workflow/Event/HasContextTrait.php b/src/Symfony/Component/Workflow/Event/HasContextTrait.php new file mode 100644 index 0000000000000..4fc3d87071691 --- /dev/null +++ b/src/Symfony/Component/Workflow/Event/HasContextTrait.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Event; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + * @author Hugo Hamon + * + * @internal + */ +trait HasContextTrait +{ + private array $context = []; + + public function getContext(): array + { + return $this->context; + } +} diff --git a/src/Symfony/Component/Workflow/Event/LeaveEvent.php b/src/Symfony/Component/Workflow/Event/LeaveEvent.php index d3d48cbd8e4f0..a50d7b3a0de6f 100644 --- a/src/Symfony/Component/Workflow/Event/LeaveEvent.php +++ b/src/Symfony/Component/Workflow/Event/LeaveEvent.php @@ -11,6 +11,18 @@ namespace Symfony\Component\Workflow\Event; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\WorkflowInterface; + final class LeaveEvent extends Event { + use HasContextTrait; + + public function __construct(object $subject, Marking $marking, Transition $transition = null, WorkflowInterface $workflow = null, array $context = []) + { + parent::__construct($subject, $marking, $transition, $workflow); + + $this->context = $context; + } } diff --git a/src/Symfony/Component/Workflow/Event/TransitionEvent.php b/src/Symfony/Component/Workflow/Event/TransitionEvent.php index 4710f90038324..e9a82a042c440 100644 --- a/src/Symfony/Component/Workflow/Event/TransitionEvent.php +++ b/src/Symfony/Component/Workflow/Event/TransitionEvent.php @@ -11,8 +11,21 @@ namespace Symfony\Component\Workflow\Event; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\WorkflowInterface; + final class TransitionEvent extends Event { + use HasContextTrait; + + public function __construct(object $subject, Marking $marking, Transition $transition = null, WorkflowInterface $workflow = null, array $context = []) + { + parent::__construct($subject, $marking, $transition, $workflow); + + $this->context = $context; + } + public function setContext(array $context): void { $this->context = $context; diff --git a/src/Symfony/Component/Workflow/README.md b/src/Symfony/Component/Workflow/README.md index 822a29d17f55a..7813d63db5fc5 100644 --- a/src/Symfony/Component/Workflow/README.md +++ b/src/Symfony/Component/Workflow/README.md @@ -7,18 +7,7 @@ machine. Sponsor ------- -The Workflow component for Symfony 6.2 is [backed][1] by [bitExpert][2]. - -Their pulse is cross-technology software development that beats with every line of code. -The basic principle for their solutions, products, and services is innovation, quality, -commitment, and professionalism. - -bitExpert actively supports Open-Source and the software development community through various -activities: Contributing to the Open-Source projects they love, organizing & hosting meetups, -speaking at conferences, and organizing unKonf - an unconference focused on web -and software development practices. - -Help Symfony by [sponsoring][3] its development! +Help Symfony by [sponsoring][1] its development! Resources --------- @@ -29,6 +18,4 @@ Resources [send Pull Requests](https://github.com/symfony/symfony/pulls) in the [main Symfony repository](https://github.com/symfony/symfony) -[1]: https://symfony.com/backers -[2]: https://www.bitexpert.de -[3]: https://symfony.com/sponsor +[1]: https://symfony.com/sponsor diff --git a/src/Symfony/Contracts/Translation/TranslatorTrait.php b/src/Symfony/Contracts/Translation/TranslatorTrait.php index ea526b83f4379..e3b0adff05980 100644 --- a/src/Symfony/Contracts/Translation/TranslatorTrait.php +++ b/src/Symfony/Contracts/Translation/TranslatorTrait.php @@ -22,7 +22,10 @@ trait TranslatorTrait { private ?string $locale = null; - public function setLocale(string $locale): void + /** + * @return void + */ + public function setLocale(string $locale) { $this->locale = $locale; }