diff --git a/Command/FixturesLoadCommand.php b/Command/FixturesLoadCommand.php new file mode 100644 index 0000000..387724e --- /dev/null +++ b/Command/FixturesLoadCommand.php @@ -0,0 +1,338 @@ + + */ +class FixturesLoadCommand extends AbstractCommand +{ + /** + * Default fixtures directory. + * @var string + */ + private $defaultFixturesDir = 'app/propel/fixtures'; + + /** + * Absolute path for fixtures directory + * @var string + */ + private $absoluteFixturesPath = ''; + + /** + * Filesystem for manipulating files + * @var \Symfony\Component\Filesystem\Filesystem + */ + private $filesystem = null; + + /** + * @see Command + */ + protected function configure() + { + $this + ->setName('propel:fixtures:load') + ->setDescription('Load XML, SQL and/or YAML fixtures') + ->setHelp(<<propel:fixtures:load loads XML, SQL and/or YAML fixtures. + + php app/console propel:fixtures:load + +The --connection parameter allows you to change the connection to use. +The default connection is the active connection (propel.dbal.default_connection). + +The --dir parameter allows you to change the directory that contains XML or/and SQL fixtures files (default: app/propel/fixtures). + +The --xml parameter allows you to load only XML fixtures. +The --sql parameter allows you to load only SQL fixtures. +The --yml parameter allows you to load only YAML fixtures. + +You can mix --xml, --sql and --yml parameters to load XML, YAML and SQL fixtures at the same time. +If none of this parameter is set, all XML, YAML and SQL files in the directory will be load. + +XML fixtures files are the same XML files you can get with the command propel:data-dump: + + + + + + + + + + + +YAML fixtures are: + + \Awesome\Object: + o1: + Title: My title + MyFoo: bar + + \Awesome\Related: + r1: + ObjectId: o1 + Description: Hello world ! + +EOT + ) + + ->addArgument('bundle', InputArgument::OPTIONAL, 'The bundle to load fixtures from') + ->addOption( + 'dir', 'd', InputOption::VALUE_OPTIONAL, + 'The directory where XML, SQL and/or YAML fixtures files are located', + $this->defaultFixturesDir + ) + ->addOption('xml', '', InputOption::VALUE_NONE, 'Load XML fixtures') + ->addOption('sql', '', InputOption::VALUE_NONE, 'Load SQL fixtures') + ->addOption('yml', '', InputOption::VALUE_NONE, 'Load YAML fixtures') + ->addOption('connection', null, InputOption::VALUE_OPTIONAL, 'Set this parameter to define a connection to use') + ; + } + + /** + * @see Command + * + * @throws \InvalidArgumentException When the target directory does not exist + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->filesystem = new Filesystem(); + + if (null !== $this->bundle) { + $this->absoluteFixturesPath = $this->getFixturesPath($this->bundle); + } else { + $this->absoluteFixturesPath = realpath($this->getApplication()->getKernel()->getRootDir() . '/../' . $input->getOption('dir')); + } + + if (!$this->absoluteFixturesPath && !file_exists($this->absoluteFixturesPath)) { + return $this->writeSection($output, array( + 'The fixtures directory "' . $this->absoluteFixturesPath . '" does not exist.' + ), 'fg=white;bg=red'); + } + + $noOptions = !$input->getOption('xml') && !$input->getOption('sql') && !$input->getOption('yml'); + + if ($input->getOption('sql') || $noOptions) { + if (-1 === $this->loadSqlFixtures($input, $output)) { + $output->writeln('No SQL fixtures found.'); + } + } + + if ($input->getOption('xml') || $noOptions) { + if (-1 === $this->loadFixtures($input, $output, 'xml')) { + $output->writeln('No XML fixtures found.'); + } + } + + if ($input->getOption('yml') || $noOptions) { + if (-1 === $this->loadFixtures($input, $output, 'yml')) { + $output->writeln('No YML fixtures found.'); + } + } + } + + /** + * Load fixtures + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + */ + protected function loadFixtures(InputInterface $input, OutputInterface $output, $type = null) + { + if (null === $type) { + return; + } + + $datas = $this->getFixtureFiles($type); + + if (count(iterator_to_array($datas)) === 0) { + return -1; + } + + $connectionName = $input->getOption('connection'); + + if ('yml' === $type) { + $loader = $this->getContainer()->get('propel.loader.yaml'); + } elseif ('xml' === $type) { + $loader = $this->getContainer()->get('propel.loader.xml'); + //$loader = new XmlDataLoader($this->getApplication()->getKernel()->getRootDir()); + } else { + return; + } + + try { + $nb = $loader->load($datas, $connectionName); + } catch (\Exception $e) { + $this->writeSection($output, array( + '[Propel] Exception', + '', + $e->getMessage()), 'fg=white;bg=red'); + + return false; + } + + $output->writeln(sprintf('%s %s fixtures file%s loaded.', $nb, strtoupper($type), $nb > 1 ? 's' : '')); + + return true; + } + + /** + * Load SQL fixtures + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + */ + protected function loadSqlFixtures(InputInterface $input, OutputInterface $output) + { + $tmpdir = $this->getApplication()->getKernel()->getRootDir() . '/cache/propel'; + $datas = $this->getFixtureFiles('sql'); + + $this->prepareCache($tmpdir); + + list($name, $defaultConfig) = $this->getConnection($input, $output); + + // Create a "sqldb.map" file + $sqldbContent = ''; + foreach ($datas as $data) { + $output->writeln(sprintf('Loading SQL fixtures from %s.', $data)); + + $sqldbContent .= $data->getFilename() . '=' . $name . PHP_EOL; + $this->filesystem->copy($data, $tmpdir . '/fixtures/' . $data->getFilename(), true); + } + + if ('' === $sqldbContent) { + return -1; + } + + $sqldbFile = $tmpdir . '/fixtures/sqldb.map'; + file_put_contents($sqldbFile, $sqldbContent); + + if (!$this->insertSql($defaultConfig, $tmpdir . '/fixtures', $tmpdir, $output)) { + return -1; + } + + $this->filesystem->remove($tmpdir); + + return 0; + } + + /** + * Prepare the cache directory + * + * @param string $tmpdir The temporary directory path. + */ + protected function prepareCache($tmpdir) + { + // Recreate a propel directory in cache + $this->filesystem->remove($tmpdir); + $this->filesystem->mkdir($tmpdir); + + $fixturesdir = $tmpdir . '/fixtures/'; + $this->filesystem->remove($fixturesdir); + $this->filesystem->mkdir($fixturesdir); + } + + /** + * Insert SQL + */ + protected function insertSql($config, $sqlDir, $schemaDir, $output) + { + // Insert SQL + $ret = $this->callPhing('insert-sql', array( + 'propel.database.url' => $config['connection']['dsn'], + 'propel.database.database' => $config['adapter'], + 'propel.database.user' => $config['connection']['user'], + 'propel.database.password' => $config['connection']['password'], + 'propel.schema.dir' => $schemaDir, + 'propel.sql.dir' => $sqlDir, + )); + + if (true === $ret) { + $output->writeln('All SQL statements have been inserted.'); + } else { + $this->writeTaskError($output, 'insert-sql', false); + + return false; + } + + return true; + } + + /** + * Returns the fixtures files to load. + * + * @param string $type The extension of the files. + * @param string $in The directory in which we search the files. If null, + * we'll use the absoluteFixturesPath property. + * + * @return \Iterator An iterator through the files. + */ + protected function getFixtureFiles($type = 'sql', $in = null) + { + $finder = new Finder(); + $finder->sortByName()->name('*.' . $type); + + $files = $finder->in(null !== $in ? $in : $this->absoluteFixturesPath); + + if (null === $this->bundle) { + return $files; + } + + $finalFixtureFiles = array(); + foreach ($files as $file) { + $fixtureFilePath = str_replace($this->getFixturesPath($this->bundle) . DIRECTORY_SEPARATOR, '', $file->getRealPath()); + $logicalName = sprintf('@%s/Resources/fixtures/%s', $this->bundle->getName(), $fixtureFilePath); + $finalFixtureFiles[] = new \SplFileInfo($this->getFileLocator()->locate($logicalName)); + } + + return new \ArrayIterator($finalFixtureFiles); + } + + /** + * Returns the path the command will look into to find fixture files + * + * @return String + */ + protected function getFixturesPath(BundleInterface $bundle) + { + return $bundle->getPath() . DIRECTORY_SEPARATOR . 'Resources' . DIRECTORY_SEPARATOR . 'fixtures'; + } + + /** + * {@inheritdoc} + */ + protected function createSubCommandInstance() + { + // useless here + } + + /** + * {@inheritdoc} + */ + protected function getSubCommandArguments(InputInterface $input) + { + // useless here + } +} diff --git a/DataFixtures/Loader/AbstractDataLoader.php b/DataFixtures/Loader/AbstractDataLoader.php new file mode 100644 index 0000000..cb258f8 --- /dev/null +++ b/DataFixtures/Loader/AbstractDataLoader.php @@ -0,0 +1,306 @@ + + */ +abstract class AbstractDataLoader extends AbstractDataHandler implements DataLoaderInterface +{ + /** + * @var array + */ + protected $deletedClasses = array(); + + /** + * @var array + */ + protected $object_references = array(); + + /** + * Transforms a file containing data in an array. + * + * @param string $file A filename. + * @return array + */ + abstract protected function transformDataToArray($file); + + /** + * {@inheritdoc} + */ + public function load($files = array(), $connectionName) + { + $nbFiles = 0; + $this->deletedClasses = array(); + + $this->loadMapBuilders($connectionName); + $this->con = $this->propel->getConnection($connectionName); + + try { + $this->con->beginTransaction(); + + $datas = array(); + foreach ($files as $file) { + $content = $this->transformDataToArray($file); + + if (count($content) > 0) { + $datas = array_merge_recursive($datas, $content); + $nbFiles++; + } + } + + $this->deleteCurrentData($datas); + $this->loadDataFromArray($datas); + + $this->con->commit(); + } catch (\Exception $e) { + $this->con->rollBack(); + throw $e; + } + + return $nbFiles; + } + + /** + * Deletes current data. + * + * @param array $data The data to delete + */ + protected function deleteCurrentData($data = null) + { + if ($data !== null) { + $classes = array_keys($data); + foreach (array_reverse($classes) as $class) { + $class = trim($class); + if (in_array($class, $this->deletedClasses)) { + continue; + } + $this->deleteClassData($class); + } + } + } + + /** + * Delete data for a given class, and for its ancestors (if any). + * + * @param string $class Class name to delete + */ + protected function deleteClassData($class) + { + $tableMap = $this->dbMap->getTable(constant(constant($class.'::TABLE_MAP').'::TABLE_NAME')); + $tableMap->doDeleteAll($this->con); + + $this->deletedClasses[] = $class; + + // Remove ancestors data + if (false !== ($parentClass = get_parent_class(get_parent_class($class)))) { + $reflectionClass = new \ReflectionClass($parentClass); + if (!$reflectionClass->isAbstract()) { + $this->deleteClassData($parentClass); + } + } + } + + /** + * Loads the data using the generated data model. + * + * @param array $data The data to be loaded + */ + protected function loadDataFromArray($data = null) + { + if ($data === null) { + return; + } + + foreach ($data as $class => $datas) { + // iterate through datas for this class + // might have been empty just for force a table to be emptied on import + if (!is_array($datas)) { + continue; + } + + $class = trim($class); + if ('\\' == $class[0]) { + $class = substr($class, 1); + } + $tableMap = $this->dbMap->getTable(constant(constant($class.'::TABLE_MAP').'::TABLE_NAME')); + $column_names = TableMap::getFieldnamesForClass($class, TableMap::TYPE_PHPNAME); + + foreach ($datas as $key => $data) { + // create a new entry in the database + if (!class_exists($class)) { + throw new \InvalidArgumentException(sprintf('Unknown class "%s".', $class)); + } + + $obj = new $class(); + + if (!$obj instanceof ActiveRecordInterface) { + throw new \RuntimeException( + sprintf('The class "%s" is not a Propel class. There is probably another class named "%s" somewhere.', $class, $class) + ); + } + + if (!is_array($data)) { + throw new \InvalidArgumentException(sprintf('You must give a name for each fixture data entry (class %s).', $class)); + } + + foreach ($data as $name => $value) { + if (is_array($value) && 's' === substr($name, -1)) { + try { + // many to many relationship + $this->loadManyToMany($obj, substr($name, 0, -1), $value); + + continue; + } catch (PropelException $e) { + // Check whether this is actually an array stored in the object. + if ('Cannot fetch TableMap for undefined table: ' . substr($name, 0, -1) === $e->getMessage()) { + if (PropelColumnTypes::PHP_ARRAY !== $tableMap->getColumn($name)->getType() + && PropelColumnTypes::OBJECT !== $tableMap->getColumn($name)->getType()) { + throw $e; + } + } + } + } + + $isARealColumn = true; + if ($tableMap->hasColumn($name)) { + $column = $tableMap->getColumn($name); + } elseif ($tableMap->hasColumnByPhpName($name)) { + $column = $tableMap->getColumnByPhpName($name); + } else { + $isARealColumn = false; + } + + // foreign key? + if ($isARealColumn) { + /* + * A column, which is a PrimaryKey (self referencing, e.g. versionable behavior), + * but which is not a ForeignKey (e.g. delegatable behavior on 1:1 relation). + */ + if ($column->isPrimaryKey() && null !== $value && !$column->isForeignKey()) { + if (isset($this->object_references[$this->cleanObjectRef($class.'_'.$value)])) { + $obj = $this->object_references[$this->cleanObjectRef($class.'_'.$value)]; + + continue; + } + } + + if ($column->isForeignKey() && null !== $value) { + $relatedTable = $this->dbMap->getTable($column->getRelatedTableName()); + if (!isset($this->object_references[$this->cleanObjectRef($relatedTable->getClassname().'_'.$value)])) { + var_dump($this->object_references, $this->cleanObjectRef($relatedTable->getClassname().'_'.$value)); + throw new \InvalidArgumentException( + sprintf('The object "%s" from class "%s" is not defined in your data file.', $value, $relatedTable->getClassname()) + ); + } + $value = $this + ->object_references[$this->cleanObjectRef($relatedTable->getClassname().'_'.$value)] + ->getByName($column->getRelatedName(), TableMap::TYPE_COLNAME); + } + } + + if (false !== $pos = array_search($name, $column_names)) { + $obj->setByPosition($pos, $value); + } elseif (is_callable(array($obj, $method = 'set'.ucfirst(PropelInflector::camelize($name))))) { + $obj->$method($value); + } else { + throw new \InvalidArgumentException(sprintf('Column "%s" does not exist for class "%s".', $name, $class)); + } + } + + $obj->save($this->con); + + $this->saveParentReference($class, $key, $obj); + } + } + } + + /** + * Save a reference to the specified object (and its ancestors) before loading them. + * + * @param string $class Class name of passed object + * @param string $key Key identifying specified object + * @param ActiveRecordInterface $obj A Propel object + */ + protected function saveParentReference($class, $key, &$obj) + { + if (!method_exists($obj, 'getPrimaryKey')) { + return; + } + + $this->object_references[$this->cleanObjectRef($class.'_'.$key)] = $obj; + + // Get parent (schema ancestor) of parent (Propel base class) in case of inheritance + if (false !== ($parentClass = get_parent_class(get_parent_class($class)))) { + + $reflectionClass = new \ReflectionClass($parentClass); + if (!$reflectionClass->isAbstract()) { + $parentObj = new $parentClass; + $parentObj->fromArray($obj->toArray()); + $this->saveParentReference($parentClass, $key, $parentObj); + } + } + } + + /** + * Loads many to many objects. + * + * @param ActiveRecordInterface $obj A Propel object + * @param string $middleTableName The middle table name + * @param array $values An array of values + */ + protected function loadManyToMany($obj, $middleTableName, $values) + { + $middleTable = $this->dbMap->getTable($middleTableName); + $middleClass = $middleTable->getClassname(); + $tableName = constant(constant(get_class($obj).'::TABLE_MAP').'::TABLE_NAME'); + + foreach ($middleTable->getColumns() as $column) { + if ($column->isForeignKey()) { + if ($tableName !== $column->getRelatedTableName()) { + $relatedClass = $this->dbMap->getTable($column->getRelatedTableName())->getClassname(); + $relatedSetter = 'set' . $column->getRelation()->getName(); + } else { + $setter = 'set' . $column->getRelation()->getName(); + } + } + } + + if (!isset($relatedClass)) { + throw new \InvalidArgumentException(sprintf('Unable to find the many-to-many relationship for object "%s".', get_class($obj))); + } + + foreach ($values as $value) { + if (!isset($this->object_references[$this->cleanObjectRef($relatedClass.'_'.$value)])) { + throw new \InvalidArgumentException( + sprintf('The object "%s" from class "%s" is not defined in your data file.', $value, $relatedClass) + ); + } + + $middle = new $middleClass(); + $middle->$setter($obj); + $middle->$relatedSetter($this->object_references[$this->cleanObjectRef($relatedClass.'_'.$value)]); + $middle->save($this->con); + } + } + + protected function cleanObjectRef($ref) + { + return $ref[0] === '\\' ? substr($ref, 1) : $ref; + } +} diff --git a/DataFixtures/Loader/DataLoaderInterface.php b/DataFixtures/Loader/DataLoaderInterface.php new file mode 100644 index 0000000..df8e8b6 --- /dev/null +++ b/DataFixtures/Loader/DataLoaderInterface.php @@ -0,0 +1,27 @@ + + */ +interface DataLoaderInterface +{ + /** + * Loads data from a set of files. + * + * @param array $files A set of files containing datas to load. + * @param string $connectionName The Propel connection name + */ + public function load($files = array(), $connectionName); +} diff --git a/DataFixtures/Loader/YamlDataLoader.php b/DataFixtures/Loader/YamlDataLoader.php new file mode 100644 index 0000000..b78cef2 --- /dev/null +++ b/DataFixtures/Loader/YamlDataLoader.php @@ -0,0 +1,78 @@ + + */ +class YamlDataLoader extends AbstractDataLoader +{ + /** + * @var \Symfony\Component\DependencyInjection\ContainerInterface + */ + private $container; + + /** + * {@inheritdoc} + */ + public function __construct($rootDir, ContainerInterface $container) + { + parent::__construct($rootDir, $container->get('propel'), $container->getParameter('propel.configuration')); + + $this->container = $container; + } + + /** + * {@inheritdoc} + */ + protected function transformDataToArray($file) + { + if (strpos($file, "\n") === false && is_file($file)) { + if (false === is_readable($file)) { + throw new ParseException(sprintf('Unable to parse "%s" as the file is not readable.', $file)); + } + + if (null !== $this->container && $this->container->has('faker.generator')) { + $generator = $this->container->get('faker.generator'); + $faker = function($type) use ($generator) { + $args = func_get_args(); + array_shift($args); + + echo Yaml::dump(call_user_func_array(array($generator, $type), $args)) . "\n"; + }; + } else { + $faker = function($text) { + echo $text . "\n"; + }; + } + + ob_start(); + $retval = include($file); + $content = ob_get_clean(); + + // if an array is returned by the config file assume it's in plain php form else in YAML + $file = is_array($retval) ? $retval : $content; + + // if an array is returned by the config file assume it's in plain php form else in YAML + if (is_array($file)) { + return $file; + } + } + + return Yaml::parse($file); + } +} diff --git a/Resources/config/propel.xml b/Resources/config/propel.xml index 85b69c1..b948478 100644 --- a/Resources/config/propel.xml +++ b/Resources/config/propel.xml @@ -14,6 +14,7 @@ Propel\PropelBundle\Twig\Extension\SyntaxExtension Propel\PropelBundle\Form\TypeGuesser Propel\PropelBundle\DataFixtures\Dumper\YamlDataDumper + Propel\PropelBundle\DataFixtures\Loader\YamlDataLoader @@ -47,5 +48,10 @@ %propel.configuration% + + + %kernel.root_dir% + +