PageRenderTime 22ms CodeModel.GetById 18ms RepoModel.GetById 0ms app.codeStats 0ms

/src/Nelmio/Alice/Loader/Base.php

https://github.com/kacperix/alice
PHP | 499 lines | 318 code | 81 blank | 100 comment | 68 complexity | 9209bc52420a6d681f3aa500dec0713c MD5 | raw file
  1. <?php
  2. /*
  3. * This file is part of the Alice package.
  4. *
  5. * (c) Nelmio <hello@nelm.io>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Nelmio\Alice\Loader;
  11. use Symfony\Component\Form\Util\FormUtil;
  12. use Nelmio\Alice\ORMInterface;
  13. use Nelmio\Alice\LoaderInterface;
  14. /**
  15. * Loads fixtures from an array or php file
  16. *
  17. * The php code if $data is a file has access to $loader->fake() to
  18. * generate data and must return an array of the format below.
  19. *
  20. * The array format must follow this example:
  21. *
  22. * array(
  23. * 'Namespace\Class' => array(
  24. * 'name' => array(
  25. * 'property' => 'value',
  26. * 'property2' => 'value',
  27. * ),
  28. * 'name2' => array(
  29. * [...]
  30. * ),
  31. * ),
  32. * )
  33. */
  34. class Base implements LoaderInterface
  35. {
  36. protected $references = array();
  37. /**
  38. * @var ORMInterface
  39. */
  40. protected $manager;
  41. /**
  42. * @var \Faker\Generator[]
  43. */
  44. private $generators;
  45. /**
  46. * Default locale to use with faker
  47. *
  48. * @var string
  49. */
  50. private $defaultLocale;
  51. /**
  52. * Custom faker providers to use with faker generator
  53. *
  54. * @var array
  55. */
  56. private $providers;
  57. /**
  58. * @var int
  59. */
  60. private $currentRangeId;
  61. /**
  62. * @param string $locale default locale to use with faker if none is
  63. * specified in the expression
  64. * @param array $providers custom faker providers in addition to the default
  65. * ones from faker
  66. * @param int $seed a seed to make sure faker generates data consistently across
  67. * runs, set to null to disable
  68. */
  69. public function __construct($locale = 'en_US', array $providers = array(), $seed = 1)
  70. {
  71. $this->defaultLocale = $locale;
  72. $this->providers = $providers;
  73. if (is_numeric($seed)) {
  74. mt_srand($seed);
  75. }
  76. }
  77. /**
  78. * {@inheritDoc}
  79. */
  80. public function load($data)
  81. {
  82. if (!is_array($data)) {
  83. // $loader is defined to give access to $loader->fake() in the included file's context
  84. $loader = $this;
  85. $filename = $data;
  86. $includeWrapper = function () use ($filename, $loader) {
  87. ob_start();
  88. $res = include $filename;
  89. ob_end_clean();
  90. return $res;
  91. };
  92. $data = $includeWrapper();
  93. if (!is_array($data)) {
  94. throw new \UnexpectedValueException('Included file "'.$filename.'" must return an array of data');
  95. }
  96. }
  97. $objects = array();
  98. foreach ($data as $class => $instances) {
  99. foreach ($instances as $name => $spec) {
  100. if (preg_match('#\{([0-9]+)\.\.(\.?)([0-9]+)\}#i', $name, $match)) {
  101. $from = $match[1];
  102. $to = empty($match[2]) ? $match[3] : $match[3] - 1;
  103. if ($from > $to) {
  104. list($to, $from) = array($from, $to);
  105. }
  106. for ($i = $from; $i <= $to; $i++) {
  107. $this->currentRangeId = $i;
  108. $objects[] = $this->createObject($class, str_replace($match[0], $i, $name), $spec);
  109. }
  110. $this->currentRangeId = null;
  111. } else {
  112. $objects[] = $this->createObject($class, $name, $spec);
  113. }
  114. }
  115. }
  116. return $objects;
  117. }
  118. /**
  119. * {@inheritDoc}
  120. */
  121. public function getReference($name, $property = null)
  122. {
  123. if (isset($this->references[$name])) {
  124. $reference = $this->references[$name];
  125. if ($property !== null) {
  126. if (property_exists($reference, $property)) {
  127. $prop = new \ReflectionProperty($reference, $property);
  128. if ($prop->isPublic()) {
  129. return $reference->{$property};
  130. }
  131. }
  132. $getter = 'get'.ucfirst($property);
  133. if (method_exists($reference, $getter) && is_callable(array($reference, $getter))) {
  134. return $reference->$getter();
  135. }
  136. throw new \UnexpectedValueException('Property '.$property.' is not defined for reference '.$name);
  137. }
  138. return $this->references[$name];
  139. }
  140. throw new \UnexpectedValueException('Reference '.$name.' is not defined');
  141. }
  142. /**
  143. * {@inheritDoc}
  144. */
  145. public function getReferences()
  146. {
  147. return $this->references;
  148. }
  149. public function fake($formatter, $locale = null, $arg = null, $arg2 = null, $arg3 = null)
  150. {
  151. $args = func_get_args();
  152. array_shift($args);
  153. array_shift($args);
  154. if ($formatter == 'current') {
  155. if ($this->currentRangeId === null) {
  156. throw new \UnexpectedValueException('Cannot use <current()> out of fixtures ranges');
  157. }
  158. return $this->currentRangeId;
  159. }
  160. return $this->getGenerator($locale)->format($formatter, $args);
  161. }
  162. /**
  163. * Get the generator for this locale
  164. *
  165. * @param string $locale the requested locale, defaults to constructor injected default
  166. *
  167. * @return \Faker\Generator the generator for the requested locale
  168. */
  169. private function getGenerator($locale = null)
  170. {
  171. $locale = $locale ?: $this->defaultLocale;
  172. if (!isset($this->generators[$locale])) {
  173. $generator = \Faker\Factory::create($locale);
  174. foreach ($this->providers as $provider) {
  175. $generator->addProvider($provider);
  176. }
  177. $this->generators[$locale] = $generator;
  178. }
  179. return $this->generators[$locale];
  180. }
  181. private function createObject($class, $name, $data)
  182. {
  183. $obj = $this->createInstance($class, $name, $data);
  184. $variables = array();
  185. foreach ($data as $key => $val) {
  186. if (is_array($val) && '{' === key($val)) {
  187. throw new \RuntimeException('Misformatted string in object '.$name.', '.$key.'\'s value should be quoted if you used yaml');
  188. }
  189. // process values
  190. $val = $this->process($val, $variables);
  191. // add relations if available
  192. if (is_array($val) && $method = $this->findAdderMethod($obj, $key)) {
  193. foreach ($val as $rel) {
  194. $rel = $this->checkTypeHints($obj, $method, $rel);
  195. $obj->{$method}($rel);
  196. }
  197. } elseif (is_array($val) && method_exists($obj, $key)) {
  198. foreach ($val as $num => $param) {
  199. $val[$num] = $this->checkTypeHints($obj, $key, $param, $num);
  200. }
  201. call_user_func_array(array($obj, $key), $val);
  202. $variables[$key] = $val;
  203. } elseif (method_exists($obj, 'set'.$key)) {
  204. $val = $this->checkTypeHints($obj, 'set'.$key, $val);
  205. $obj->{'set'.$key}($val);
  206. $variables[$key] = $val;
  207. } elseif (property_exists($obj, $key)) {
  208. $refl = new \ReflectionProperty($obj, $key);
  209. $refl->setAccessible(true);
  210. $refl->setValue($obj, $val);
  211. $variables[$key] = $val;
  212. } else {
  213. throw new \UnexpectedValueException('Could not determine how to assign '.$key.' to a '.$class.' object');
  214. }
  215. }
  216. return $this->references[$name] = $obj;
  217. }
  218. private function createInstance($class, $name, array &$data)
  219. {
  220. try {
  221. // constructor is defined explicitly
  222. if (isset($data['__construct'])) {
  223. $args = $data['__construct'];
  224. unset($data['__construct']);
  225. // constructor override
  226. if (false === $args) {
  227. if (version_compare(PHP_VERSION, '5.4', '<')) {
  228. // unserialize hack for php <5.4
  229. return unserialize(sprintf('O:%d:"%s":0:{}', strlen($class), $class));
  230. }
  231. $reflClass = new \ReflectionClass($class);
  232. return $reflClass->newInstanceWithoutConstructor();
  233. }
  234. if (!is_array($args)) {
  235. throw new \UnexpectedValueException('The __construct call in object '.$name.' must be defined as an array of arguments or false to bypass it');
  236. }
  237. // create object with given args
  238. $reflClass = new \ReflectionClass($class);
  239. $args = $this->process($args, array());
  240. foreach ($args as $num => $param) {
  241. $args[$num] = $this->checkTypeHints($class, '__construct', $param, $num);
  242. }
  243. return $reflClass->newInstanceArgs($args);
  244. }
  245. // call the constructor if it contains optional params only
  246. $reflMethod = new \ReflectionMethod($class, '__construct');
  247. if (0 === $reflMethod->getNumberOfRequiredParameters()) {
  248. return new $class();
  249. }
  250. // exception otherwise
  251. throw new \RuntimeException('You must specify a __construct method with its arguments in object '.$name.' since class '.$class.' has mandatory constructor arguments');
  252. } catch (\ReflectionException $exception) {
  253. return new $class();
  254. }
  255. }
  256. /**
  257. * Checks if the value is typehinted with a class and if the current value can be coerced into that type
  258. *
  259. * It can either convert to datetime or attempt to fetched from the db by id
  260. *
  261. * @param mixed $obj instance or class name
  262. * @param string $method
  263. * @param string $value
  264. * @param integer $pNum
  265. * @return mixed
  266. */
  267. private function checkTypeHints($obj, $method, $value, $pNum = 0)
  268. {
  269. if (!is_numeric($value) && !is_string($value)) {
  270. return $value;
  271. }
  272. $reflection = new \ReflectionMethod($obj, $method);
  273. $params = $reflection->getParameters();
  274. if (!$params[$pNum]->getClass()) {
  275. return $value;
  276. }
  277. $hintedClass = $params[$pNum]->getClass()->getName();
  278. if ($hintedClass === 'DateTime') {
  279. try {
  280. if (preg_match('{^[0-9]+$}', $value)) {
  281. $value = '@'.$value;
  282. }
  283. return new \DateTime($value);
  284. } catch (\Exception $e) {
  285. throw new \UnexpectedValueException('Could not convert '.$value.' to DateTime for '.$reflection->getDeclaringClass()->getName().'::'.$method, 0, $e);
  286. }
  287. }
  288. if (is_numeric($value)) {
  289. if (!$this->manager) {
  290. throw new \LogicException('To reference objects by id you must first set a Nelmio\Alice\ORMInterface object on this instance');
  291. }
  292. $value = $this->manager->find($hintedClass, $value);
  293. }
  294. return $value;
  295. }
  296. private function process($data, array $variables)
  297. {
  298. if (is_array($data)) {
  299. foreach ($data as $key => $val) {
  300. $data[$key] = $this->process($val, $variables);
  301. }
  302. return $data;
  303. }
  304. // check for conditional values (20%? true : false)
  305. if (is_string($data) && preg_match('{^(?<threshold>[0-9.]+%?)\? (?<true>.+?)(?: : (?<false>.+?))?$}', $data, $match)) {
  306. // process true val since it's always needed
  307. $trueVal = $this->process($match['true'], $variables);
  308. // compute threshold and check if we are beyond it
  309. $threshold = $match['threshold'];
  310. if (substr($threshold, -1) === '%') {
  311. $threshold = substr($threshold, 0, -1) / 100;
  312. }
  313. $randVal = mt_rand(0, 100) / 100;
  314. if ($threshold > 0 && $randVal <= $threshold) {
  315. return $trueVal;
  316. } else {
  317. $emptyVal = is_array($trueVal) ? array() : null;
  318. if (isset($match['false']) && '' !== $match['false']) {
  319. return $this->process($match['false'], $variables);
  320. }
  321. return $emptyVal;
  322. }
  323. }
  324. // return non-string values
  325. if (!is_string($data)) {
  326. return $data;
  327. }
  328. $that = $this;
  329. // replaces a placeholder by the result of a ->fake call
  330. $replacePlaceholder = function ($matches) use ($variables, $that) {
  331. $args = isset($matches['args']) && '' !== $matches['args'] ? $matches['args'] : null;
  332. if (!$args) {
  333. return $that->fake($matches['name'], $matches['locale']);
  334. }
  335. // replace references to other variables in the same object
  336. $args = preg_replace_callback('{\{?\$([a-z0-9_]+)\}?}i', function ($match) use ($variables) {
  337. if (isset($variables[$match[1]])) {
  338. return '$variables['.var_export($match[1], true).']';
  339. }
  340. return $match[0];
  341. }, $args);
  342. $locale = var_export($matches['locale'], true);
  343. $name = var_export($matches['name'], true);
  344. return eval('return $that->fake(' . $name . ', ' . $locale . ', ' . $args . ');');
  345. };
  346. // format placeholders without preg_replace if there is only one to avoid __toString() being called
  347. $placeHolderRegex = '<(?:(?<locale>[a-z]+(?:_[a-z]+)?):)?(?<name>[a-z0-9_]+?)\((?<args>(?:[^)]*|\)(?!>))*)\)>';
  348. if (preg_match('#^'.$placeHolderRegex.'$#i', $data, $matches)) {
  349. $data = $replacePlaceholder($matches);
  350. } else {
  351. // format placeholders inline
  352. $data = preg_replace_callback('#'.$placeHolderRegex.'#i', function ($matches) use ($replacePlaceholder) {
  353. return $replacePlaceholder($matches);
  354. }, $data);
  355. }
  356. // process references
  357. if (is_string($data) && preg_match('{^(?:(?<multi>\d+)x )?@(?<reference>[a-z0-9_.*-]+)(?:\->(?<property>[a-z0-9_-]+))?$}i', $data, $matches)) {
  358. $multi = ('' !== $matches['multi']) ? $matches['multi'] : null;
  359. $property = isset($matches['property']) ? $matches['property'] : null;
  360. if (strpos($matches['reference'], '*')) {
  361. $data = $this->getRandomReferences($matches['reference'], $multi, $property);
  362. } else {
  363. if (null !== $multi) {
  364. throw new \UnexpectedValueException('To use multiple references you must use a mask like "'.$matches['multi'].'x @user*", otherwise you would always get only one item.');
  365. }
  366. $data = $this->getReference($matches['reference'], $property);
  367. }
  368. }
  369. return $data;
  370. }
  371. private function getRandomReferences($mask, $count = 1, $property = null)
  372. {
  373. if ($count === 0) {
  374. return array();
  375. }
  376. $availableRefs = array();
  377. foreach ($this->references as $key => $val) {
  378. if (preg_match('{^'.str_replace('*', '.+', $mask).'$}', $key)) {
  379. $availableRefs[] = $key;
  380. }
  381. }
  382. if (!$availableRefs) {
  383. throw new \UnexpectedValueException('Reference mask "'.$mask.'" did not match any existing reference, make sure the object is created after its references');
  384. }
  385. if (null === $count) {
  386. return $this->getReference($availableRefs[mt_rand(0, count($availableRefs) - 1)], $property);
  387. }
  388. $res = array();
  389. while ($count-- && $availableRefs) {
  390. $ref = array_splice($availableRefs, mt_rand(0, count($availableRefs) - 1), 1);
  391. $res[] = $this->getReference(current($ref), $property);
  392. }
  393. return $res;
  394. }
  395. private function findAdderMethod($obj, $key)
  396. {
  397. if (method_exists($obj, $method = 'add'.$key)) {
  398. return $method;
  399. }
  400. if (class_exists('Symfony\Component\Form\Util\FormUtil') && method_exists('Symfony\Component\Form\Util\FormUtil', 'singularify')) {
  401. foreach ((array) FormUtil::singularify($key) as $singularForm) {
  402. if (method_exists($obj, $method = 'add'.$singularForm)) {
  403. return $method;
  404. }
  405. }
  406. }
  407. if (method_exists($obj, $method = 'add'.rtrim($key, 's'))) {
  408. return $method;
  409. }
  410. if (substr($key, -3) === 'ies' && method_exists($obj, $method = 'add'.substr($key, 0, -3).'y')) {
  411. return $method;
  412. }
  413. }
  414. public function setORM(ORMInterface $manager)
  415. {
  416. $this->manager = $manager;
  417. }
  418. }