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();
+ }
+}