diff --git a/Resources/doc/index.markdown b/Resources/doc/index.markdown index 3b48344..37d853f 100644 --- a/Resources/doc/index.markdown +++ b/Resources/doc/index.markdown @@ -113,6 +113,7 @@ Now you can read more about: * [The Fixtures](fixtures.markdown); * [The PropelParamConverter](param_converter.markdown); * [The UniqueObjectValidator](unique_object_validator.markdown). +* [The ModelTranslation](model_translation.markdown). ## Bundle Inheritance ## diff --git a/Resources/doc/model_translation.markdown b/Resources/doc/model_translation.markdown new file mode 100644 index 0000000..03ae0b6 --- /dev/null +++ b/Resources/doc/model_translation.markdown @@ -0,0 +1,83 @@ +ModelTranslation +================ + +The `PropelBundle` provides a model-based implementation of the Translation components' loader and dumper. +To make us of this `ModelTranslation` you only need to add the translation resource. + +``` yaml +services: + translation.loader.propel: + class: Propel\PropelBundle\Translation\ModelTranslation + arguments: + # The model to be used. + - 'Acme\Model\Translation\Translation' + # The column mappings to interact with the model. + - + columns: + key: 'key' + translation: 'translation' + locale: 'locale' + domain: 'domain' + updated_at: 'updated_at' + calls: + - [ 'registerResources', [ '@translator' ] ] + tags: + - { name: 'translation.loader', alias: 'propel' } + # The dumper tag is optional. + - { name: 'translation.dumper', alias: 'propel' } +``` + +This will add another resource to the translator to be scanned for translations. + +## Translation model + +An example model schema for the translation model: + +```xml + + + + + + + + + + + + + + + + + + + + + + + +
+
+``` + +### VersionableBehavior + +In order to make use of the `VersionableBehavior` (or similar), you can map the `updated_at` column to the `version_created_at` column: + +``` yaml +services: + translation.loader.propel: + class: Propel\PropelBundle\Translation\ModelTranslation + arguments: + - 'Acme\Model\Translation\Translation' + - + columns: + updated_at: 'version_created_at' + calls: + - [ 'registerResources', [ '@translator' ] ] + tags: + - { name: 'translation.loader', alias: 'propel' } +``` + +[Back to index](index.markdown) diff --git a/Tests/Fixtures/translation_schema.xml b/Tests/Fixtures/translation_schema.xml new file mode 100644 index 0000000..594a7f6 --- /dev/null +++ b/Tests/Fixtures/translation_schema.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/Tests/TestCase.php b/Tests/TestCase.php index c5d0675..9bebac5 100644 --- a/Tests/TestCase.php +++ b/Tests/TestCase.php @@ -20,10 +20,10 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; */ class TestCase extends \PHPUnit_Framework_TestCase { - protected function setUp() + public static function setUpBeforeClass() { if (!file_exists($file = __DIR__ . '/../vendor/propel/propel1/runtime/lib/Propel.php')) { - $this->markTestSkipped('Propel is not available.'); + self::markTestSkipped('Propel is not available.'); } require_once $file; diff --git a/Tests/Translation/ModelTranslationTest.php b/Tests/Translation/ModelTranslationTest.php new file mode 100644 index 0000000..a41993c --- /dev/null +++ b/Tests/Translation/ModelTranslationTest.php @@ -0,0 +1,206 @@ + + * + * @covers Propel\PropelBundle\Translation\ModelTranslation + */ +class ModelTranslationTest extends TestCase +{ + const MODEL_CLASS = 'Propel\PropelBundle\Tests\Fixtures\Model\Translation'; + + /** + * @var \PropelPDO + */ + protected $con; + + public function setUp() + { + parent::setUp(); + + $this->loadPropelQuickBuilder(); + + $schema = file_get_contents(__DIR__.'/../Fixtures/translation_schema.xml'); + + $builder = new \PropelQuickBuilder(); + $builder->setSchema($schema); + if (class_exists('Propel\PropelBundle\Tests\Fixtures\Model\map\TranslationTableMap')) { + $builder->setClassTargets(array()); + } + + $this->con = $builder->build(); + } + + public function testRegisterResources() + { + $translation = new Entry(); + $translation + ->setKey('example.key') + ->setMessage('This is an example translation.') + ->setLocale('en_US') + ->setDomain('test') + ->setUpdatedAt(new \DateTime()) + ->save() + ; + + $resource = $this->getResource(); + + $translator = $this->getMock('Symfony\Component\Translation\Translator', array(), array('en_US')); + $translator + ->expects($this->once()) + ->method('addResource') + ->with('propel', $resource, 'en_US', 'test') + ; + + $resource->registerResources($translator); + } + + public function testIsFreshWithoutEntries() + { + $resource = $this->getResource(); + + $this->assertTrue($resource->isFresh(date('U'))); + } + + public function testIsFreshUpdates() + { + $date = new \DateTime('-2 minutes'); + + $translation = new Entry(); + $translation + ->setKey('example.key') + ->setMessage('This is an example translation.') + ->setLocale('en_US') + ->setDomain('test') + ->setUpdatedAt($date) + ->save() + ; + + $resource = $this->getResource(); + + $timestamp = (int) $date->format('U'); + + $this->assertFalse($resource->isFresh($timestamp - 10)); + } + + public function testLoadInvalidResource() + { + $invalidResource = $this->getMock('Symfony\Component\Config\Resource\ResourceInterface'); + + $resource = $this->getResource(); + $catalogue = $resource->load($invalidResource, 'en_US'); + + $this->assertEmpty($catalogue->getResources()); + } + + public function testLoadFiltersLocaleAndDomain() + { + $date = new \DateTime(); + + $translation = new Entry(); + $translation + ->setKey('example.key') + ->setMessage('This is an example translation.') + ->setLocale('en_US') + ->setDomain('test') + ->setUpdatedAt($date) + ->save() + ; + + // different locale + $translation = new Entry(); + $translation + ->setKey('example.key') + ->setMessage('Das ist eine Beispielübersetzung.') + ->setLocale('de_DE') + ->setDomain('test') + ->setUpdatedAt($date) + ->save() + ; + + // different domain + $translation = new Entry(); + $translation + ->setKey('example.key') + ->setMessage('This is an example translation.') + ->setLocale('en_US') + ->setDomain('test2') + ->setUpdatedAt($date) + ->save() + ; + + $resource = $this->getResource(); + $catalogue = $resource->load($resource, 'en_US', 'test'); + + $this->assertInstanceOf('Symfony\Component\Translation\MessageCatalogue', $catalogue); + $this->assertEquals('en_US', $catalogue->getLocale()); + + $expected = array( + 'test' => array( + 'example.key' => 'This is an example translation.', + ), + ); + + $this->assertEquals($expected, $catalogue->all()); + } + + public function testDump() + { + $catalogue = new MessageCatalogue('en_US', array( + 'test' => array( + 'example.key' => 'This is an example translation.', + ), + 'test2' => array( + 'example.key' => 'This is an example translation.', + ), + )); + + $resource = $this->getResource(); + $this->assertEmpty($resource->load($resource, 'en_US', 'test')->all()); + + $resource->dump($catalogue); + + $stmt = $this->con->prepare('SELECT `key`, `message`, `locale`, `domain` FROM `translation`;'); + $stmt->execute(); + + $result = array(); + while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $result[] = $row; + } + + $expected = array( + array( + 'key' => 'example.key', + 'message' => 'This is an example translation.', + 'locale' => 'en_US', + 'domain' => 'test', + ), + array( + 'key' => 'example.key', + 'message' => 'This is an example translation.', + 'locale' => 'en_US', + 'domain' => 'test2', + ), + ); + + $this->assertEquals($expected, $result); + } + + protected function getResource() + { + return new ModelTranslation(self::MODEL_CLASS, array( + 'columns' => array( + 'translation' => 'message', + ), + )); + } +} diff --git a/Translation/ModelTranslation.php b/Translation/ModelTranslation.php new file mode 100644 index 0000000..cdda0cd --- /dev/null +++ b/Translation/ModelTranslation.php @@ -0,0 +1,197 @@ + + */ +class ModelTranslation implements DumperInterface, LoaderInterface, ResourceInterface +{ + protected $className; + protected $query; + + protected $options = array( + 'columns' => array( + // The key and its translation .. + 'key' => 'key', + 'translation' => 'translation', + // .. for the given locale .. + 'locale' => 'locale', + // .. under this domain. + 'domain' => 'domain', + // The datetime of the last update. + 'updated_at' => 'updated_at', + ), + ); + + private $resourcesStatement; + + public function __construct($className, array $options = array(), \ModelCriteria $query = null) + { + $this->className = $className; + $this->options = array_replace_recursive($this->options, $options); + + if (!$query) { + $query = \PropelQuery::from($this->className); + } + $this->query = $query; + } + + /** + * {@inheritdoc} + */ + public function registerResources(Translator $translator) + { + $stmt = $this->getResourcesStatement(); + + if (false === $stmt->execute()) { + throw new \RuntimeException('Could not fetch translation data from database.'); + } + + $stmt->bindColumn('locale', $locale); + $stmt->bindColumn('domain', $domain); + + while ($stmt->fetch()) { + $translator->addResource('propel', $this, $locale, $domain); + } + } + + /** + * {@inheritdoc} + */ + public function load($resource, $locale, $domain = 'messages') + { + // The loader only accepts itself as a resource. + if ($resource !== $this) { + return new MessageCatalogue($locale); + } + + $query = clone $this->query; + $query + ->filterBy($this->getColumnPhpname('locale'), $locale) + ->filterBy($this->getColumnPhpname('domain'), $domain) + ; + + $translations = $query->find(); + + $catalogue = new MessageCatalogue($locale); + foreach ($translations as $eachTranslation) { + $key = $eachTranslation->getByName($this->getColumnPhpname('key')); + $message = $eachTranslation->getByName($this->getColumnPhpname('translation')); + + $catalogue->set($key, $message, $domain); + } + + return $catalogue; + } + + /** + * {@inheritdoc} + */ + public function dump(MessageCatalogue $messages, $options = array()) + { + $connection = \Propel::getConnection($this->query->getDbName()); + $connection->beginTransaction(); + + $now = new \DateTime(); + + $locale = $messages->getLocale(); + foreach ($messages->getDomains() as $eachDomain) { + foreach ($messages->all($eachDomain) as $eachKey => $eachTranslation) { + $query = clone $this->query; + $query + ->filterBy($this->getColumnPhpname('locale'), $locale) + ->filterBy($this->getColumnPhpname('domain'), $eachDomain) + ->filterBy($this->getColumnPhpname('key'), $eachKey) + ; + + $translation = $query->findOneOrCreate($connection); + $translation->setByName($this->getColumnPhpname('translation'), (string) $eachTranslation); + $translation->setByName($this->getColumnPhpname('updated_at'), $now); + + $translation->save($connection); + } + } + + if (!$connection->commit()) { + $connection->rollBack(); + + throw new \RuntimeException(sprintf('An error occurred while committing the transaction. [%s: %s]', $connection->errorCode(), $connection->errorInfo())); + } + } + + /** + * {@inheritdoc} + */ + public function isFresh($timestamp) + { + $query = clone $this->query; + $query->filterBy($this->getColumnPhpname('updated_at'), new \DateTime('@'.$timestamp), \ModelCriteria::GREATER_THAN); + + return !$query->exists(); + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + return sprintf('PropelModelTranslation::%s', $this->className); + } + + /** + * {@inheritdoc} + */ + public function getResource() + { + return $this; + } + + /** + * Creates and caches a PDO Statement to receive available resources. + * + * @return \PDOStatement + */ + private function getResourcesStatement() + { + if ($this->resourcesStatement instanceof \PDOStatement) { + return $this->resourcesStatement; + } + + $sql = vsprintf('SELECT DISTINCT `%s` AS `locale`, `%s` AS `domain` FROM `%s`', array( + // SELECT .. + $this->query->getTableMap()->getColumn($this->getColumnname('locale'))->getName(), + $this->query->getTableMap()->getColumn($this->getColumnname('domain'))->getName(), + // FROM .. + $this->query->getTableMap()->getName(), + )); + + $connection = \Propel::getConnection($this->query->getDbName(), \Propel::CONNECTION_READ); + + $stmt = $connection->prepare($sql); + $stmt->setFetchMode(\PDO::FETCH_BOUND); + + $this->resourcesStatement = $stmt; + + return $stmt; + } + + private function getColumnname($column) + { + return $this->options['columns'][$column]; + } + + private function getColumnPhpname($column) + { + return $this->query->getTableMap()->getColumn($this->getColumnname($column))->getPhpName(); + } +}