diff --git a/doc/book/table-gateway.md b/doc/book/table-gateway.md index 9bf745f810..9dd2bf6564 100644 --- a/doc/book/table-gateway.md +++ b/doc/book/table-gateway.md @@ -215,3 +215,99 @@ There are a number of features built-in and shipped with zend-db: $artistRow->name = 'New Name'; $artistRow->save(); ``` +- `SequenceFeature`: the ability to integrate with Oracle, PostgreSQL, + (TODO: and SqlServer 2016) sequence objects. Sequences are used for generating sequential integers. + These are usually used for automatically generating Primary Key IDs (similar to MySQL's + `AUTO_INCREMENT` but without performance penalties), or ensuring uniqueness of entity IDs + across multiple tables following some business rule (for example, unique invoice numbers across + multiple order systems). Sequence objects are exclusively used for incrementing an integer + by specified interval (1 by default) and are not tied to table values. Therefore, + they need to be created manually prior to inserting data into tables requiring PKs and `UNIQUE` + columns using DDL + ```sql + CREATE SEQUENCE album_id; + ``` + + Sequence's `NEXTVAL` SQL construct can be used either as a default value for a column specified in + table's `CREATE` DDL, + or ran manually at every `insert` operation to have next available integer captured and inserted + in a table along with the rest of the values. + + Unless need to guarantee uniqueness across all tables, thus calling `sequence_name.nextval` on every `insert` + query across entire codebase, usually a separate sequence is created per table. Every `insert` + statement would have `album_id.nextval`, `artist_id.nextval` etc. as one of the values along with + the actual data. + + To be able to do these operations at the DB abstraction level, `TableGateway` needs to be informed + what columns should be managed by what sequence. + + If developer chooses to manually create a sequence for each table's autoincremented column (in Oracle + prior to *12c* this was the only way), then the name of sequence responsible for particular table + would known and can be applied to `TableGateway` right away. + + ```php + $table = new TableGateway('artist', $adapter, new Feature\SequenceFeature('id', 'artist_id_sequence')); + + $table->insert(['name'] => 'New Name'; + $nextId = $table->nextSequenceId('id'); + $lastInsertId = $table->lastSequenceId('id'); + ``` + + However, PostgreSQL (TODO: and Oracle since *12c*) allows automatic creation of sequences during `CREATE TABLE` + or `ALTER TABLE` operation by specifying column type `SERIAL`: + + ```sql + CREATE TABLE artist + { + id SERIAL, + name CHARACTER VARYING (30) + }; + ```` + + Or using Zend's `Db\Sql\DDL` + + ```php + $table = new CreateTable('artist'); + + $idColumn = new Serial('id'); + $nameColumn = new Char('name'); + + $table->addColumn($idColumn); + $table->addColumn($nameColumn); + ``` + + In this case, sequence is created automatically. `TableGateway` still has to be aware of what column + is getting autoincrement treatment but without knowing exactly what the sequence name is, second parameter + should be left blank: + + ```php + $table = new TableGateway('artist' $adapter, new Feature\SequenceFeature('id'); + ``` + + With second parameter left null, `TableGateway` will generate sequence name based on same rule + PostgreSQL uses (*tablename_columnname_seq* but if the resultant name is determined to be greater than + 63 bytes, an additional query will be made to database schema to find what PostgreSQL has created + instead, since transaction rules are more complex. + + This is important to know if you have long table and column names, and do not want + to run an extra metadata query on every `TableGateway` object construction. If that is the case, + take note of what PostgreSQL created using + ```sql + pg_get_serial_sequence('artist', 'id'); + ``` + + and add it to `SequenceFeature` constructor. + + There could be complex business rules needing multiple sequences in a table. `TableGateway` can have + multiple sequences added in an array: + + ```php + $table = new TableGateway('wholesales', $adapter, [ + new Feature\Sequence('id', 'sale_id_sequence'), + new Feature\Sequence('invoice_id', 'invoice_id_sequence)' + ]); + ``` + + Then calls to `$table->lastSequenceId('invoice_id')` will find the appropriate sequence instance to + get ID from. + \ No newline at end of file diff --git a/src/Sql/Ddl/Column/Serial.php b/src/Sql/Ddl/Column/Serial.php new file mode 100644 index 0000000000..c98f31fb0d --- /dev/null +++ b/src/Sql/Ddl/Column/Serial.php @@ -0,0 +1,23 @@ +tableGateway = $tableGateway; - foreach ($this->features as $feature) { - $feature->setTableGateway($this->tableGateway); + foreach ($this->features as $featureClass => $featureSet) { + foreach ($featureSet as $feature) { + $feature->setTableGateway($this->tableGateway); + } } return $this; } public function getFeatureByClassName($featureClassName) { - $feature = false; - foreach ($this->features as $potentialFeature) { - if ($potentialFeature instanceof $featureClassName) { - $feature = $potentialFeature; - break; - } + if (!array_key_exists($featureClassName, $this->features)) { + return false; + } + + $featuresByType = $this->features[$featureClassName]; + if (count($featuresByType) == 1) { + return $featuresByType[0]; + } else { + return $featuresByType; } - return $feature; } public function addFeatures(array $features) @@ -69,17 +73,19 @@ public function addFeature(AbstractFeature $feature) if ($this->tableGateway instanceof TableGatewayInterface) { $feature->setTableGateway($this->tableGateway); } - $this->features[] = $feature; + $this->features[get_class($feature)][] = $feature; return $this; } public function apply($method, $args) { - foreach ($this->features as $feature) { - if (method_exists($feature, $method)) { - $return = call_user_func_array([$feature, $method], $args); - if ($return === self::APPLY_HALT) { - break; + foreach ($this->features as $featureClass => $featureSet) { + foreach ($featureSet as $feature) { + if (method_exists($feature, $method)) { + $return = call_user_func_array([$feature, $method], $args); + if ($return === self::APPLY_HALT) { + break; + } } } } @@ -132,8 +138,8 @@ public function callMagicSet($property, $value) public function canCallMagicCall($method) { if (!empty($this->features)) { - foreach ($this->features as $feature) { - if (method_exists($feature, $method)) { + foreach ($this->features as $featureClass => $featuresSet) { + if (method_exists($featureClass, $method)) { return true; } } @@ -149,9 +155,19 @@ public function canCallMagicCall($method) */ public function callMagicCall($method, $arguments) { - foreach ($this->features as $feature) { - if (method_exists($feature, $method)) { - return $feature->$method($arguments); + foreach ($this->features as $featureClass => $featuresSet) { + if (method_exists($featureClass, $method)) { + + // iterator management instead of foreach to avoid extra conditions and indentations + reset($featuresSet); + $featureReturn = null; + while ($featureReturn === null) { + $current = current($featuresSet); + $featureReturn = $current->$method($arguments); + next($featuresSet); + } + + return $featureReturn; } } diff --git a/src/TableGateway/Feature/SequenceFeature.php b/src/TableGateway/Feature/SequenceFeature.php index 3c72fd3b6f..ca7c8d6c82 100644 --- a/src/TableGateway/Feature/SequenceFeature.php +++ b/src/TableGateway/Feature/SequenceFeature.php @@ -1,24 +1,28 @@ primaryKeyField = $primaryKeyField; + $this->sequencedColumn = $sequencedColumn; $this->sequenceName = $sequenceName; } + /** + * @return string + */ + public function getSequenceName() + { + //@TODO move to PostgreSQL specific class (possibly decorator) + /** @var Adapter $adapter */ + $adapter = $this->tableGateway->getAdapter(); + $platform = $adapter->getPlatform(); + + if ($this->sequenceName !== null) { + if (is_array($this->sequenceName)) { + $this->sequenceName = $platform->quoteIdentifierChain($this->sequenceName); + } + + return $this->sequenceName; + } + + $tableIdentifier = $this->tableGateway->getTable(); + // need to preserve table name in case have to query postgres metadata + // (case for large resultant identifier names) + $tableName = ''; + + $sequenceSuffix = '_'.$this->sequencedColumn.'_seq'; + // To find whether exceed identifier length, need to keep track of combination of + // table name ane suffix but not including schema name. + // Since schema has to be appended in the end, + $sequenceObjectName = ''; + + if (is_string($tableIdentifier)) { + $tableName = $tableIdentifier; + + $sequenceObjectName = $this->sequenceName = $tableIdentifier.$sequenceSuffix; + } elseif (is_array($tableIdentifier)) { + // assuming key 0 is schema name + $tableName = $tableIdentifier[1]; + + $this->sequenceName = $tableIdentifier; + $this->sequenceName[1] = $tableName.$sequenceSuffix; + $sequenceObjectName = $this->sequenceName[1]; + } elseif ($tableIdentifier instanceof TableIdentifier) { + $tableName = $tableIdentifier->getTable(); + $sequenceObjectName = $tableName.$sequenceSuffix; + $this->sequenceName = $tableIdentifier->hasSchema() ? [$tableIdentifier->getSchema(), $sequenceObjectName] : $sequenceObjectName; + } + + if (strlen($sequenceObjectName) < 64) { + $this->sequenceName = $platform->quoteIdentifierChain($this->sequenceName); + + return $this->sequenceName; + } + + $statement = $adapter->createStatement(); + $statement->prepare('SELECT pg_get_serial_sequence(:table, :column)'); + $result = $statement->execute(['table' => $tableIdentifier, 'column' => $this->sequencedColumn]); + $this->sequenceName = $result->current()['pg_get_serial_sequence']; + + // there could be a benefit porting this algorithm here instead of extra query call + // https://github.com/postgres/postgres/blob/f0e44751d7175fa3394da2c8f85e3ceb3cdbfe63/src/backend/commands/indexcmds.c#L1485 + + return $this->sequenceName; + } + /** * @param Insert $insert + * * @return Insert */ public function preInsert(Insert $insert) { $columns = $insert->getRawState('columns'); $values = $insert->getRawState('values'); - $key = array_search($this->primaryKeyField, $columns); + $key = array_search($this->sequencedColumn, $columns); if ($key !== false) { $this->sequenceValue = $values[$key]; + return $insert; } @@ -60,13 +128,14 @@ public function preInsert(Insert $insert) return $insert; } - $insert->values([$this->primaryKeyField => $this->sequenceValue], Insert::VALUES_MERGE); + $insert->values([$this->sequencedColumn => $this->sequenceValue], Insert::VALUES_MERGE); + return $insert; } /** * @param StatementInterface $statement - * @param ResultInterface $result + * @param ResultInterface $result */ public function postInsert(StatementInterface $statement, ResultInterface $result) { @@ -77,57 +146,84 @@ public function postInsert(StatementInterface $statement, ResultInterface $resul /** * Generate a new value from the specified sequence in the database, and return it. + * + * @param $columnName string Column name which this sequence instance is expected to manage. + * If expectation does not match, ignore the call. + * * @return int */ - public function nextSequenceId() + public function nextSequenceId($columnName = null) { - $platform = $this->tableGateway->adapter->getPlatform(); + if ($columnName !== null && strcmp($columnName, $this->sequencedColumn) !== 0) { + return; + } + + /** @var Adapter $adapter */ + $adapter = $this->tableGateway->adapter; + $platform = $adapter->getPlatform(); $platformName = $platform->getName(); switch ($platformName) { case 'Oracle': - $sql = 'SELECT ' . $platform->quoteIdentifier($this->sequenceName) . '.NEXTVAL as "nextval" FROM dual'; + $sql = 'SELECT '.$platform->quoteIdentifier($this->sequenceName).'.NEXTVAL as "nextval" FROM dual'; + $param = []; break; case 'PostgreSQL': - $sql = 'SELECT NEXTVAL(\'"' . $this->sequenceName . '"\')'; + $sql = 'SELECT NEXTVAL( :sequence_name )'; + $param = ['sequence_name' => $this->getSequenceName()]; break; default : return; } - $statement = $this->tableGateway->adapter->createStatement(); + $statement = $adapter->createStatement(); $statement->prepare($sql); - $result = $statement->execute(); + $result = $statement->execute($param); $sequence = $result->current(); unset($statement, $result); + return $sequence['nextval']; } /** * Return the most recent value from the specified sequence in the database. + * + * @param $columnName string Column name which this sequence instance is expected to manage. + * If expectation does not match, ignore the call. + * * @return int */ - public function lastSequenceId() + public function lastSequenceId($columnName = null) { - $platform = $this->tableGateway->adapter->getPlatform(); + if ($columnName !== null && strcmp($columnName, $this->sequencedColumn) !== 0) { + return; + } + + /** @var Adapter $adapter */ + $adapter = $this->tableGateway->adapter; + $platform = $adapter->getPlatform(); $platformName = $platform->getName(); switch ($platformName) { case 'Oracle': - $sql = 'SELECT ' . $platform->quoteIdentifier($this->sequenceName) . '.CURRVAL as "currval" FROM dual'; + $sql = 'SELECT '.$platform->quoteIdentifier($this->sequenceName).'.CURRVAL as "currval" FROM dual'; + $param = []; break; case 'PostgreSQL': - $sql = 'SELECT CURRVAL(\'' . $this->sequenceName . '\')'; + $sql = 'SELECT CURRVAL( :sequence_name )'; + $param = ['sequence_name' => $this->getSequenceName()]; break; + //@TODO add SQLServer2016 default : return; } - $statement = $this->tableGateway->adapter->createStatement(); + $statement = $adapter->createStatement(); $statement->prepare($sql); - $result = $statement->execute(); + $result = $statement->execute($param); $sequence = $result->current(); unset($statement, $result); + return $sequence['currval']; } } diff --git a/test/Sql/Ddl/Column/SerialTest.php b/test/Sql/Ddl/Column/SerialTest.php new file mode 100644 index 0000000000..5b8feef9e9 --- /dev/null +++ b/test/Sql/Ddl/Column/SerialTest.php @@ -0,0 +1,38 @@ +assertEquals( + [['%s %s NOT NULL', ['id', 'SERIAL'], [$column::TYPE_IDENTIFIER, $column::TYPE_LITERAL]]], + $column->getExpressionData() + ); + + $column = new Serial('id'); + $column->addConstraint(new PrimaryKey()); + $this->assertEquals( + [ + ['%s %s NOT NULL', ['id', 'SERIAL'], [$column::TYPE_IDENTIFIER, $column::TYPE_LITERAL]], + ' ', + ['PRIMARY KEY', [], []], + ], + $column->getExpressionData() + ); + } +} diff --git a/test/TableGateway/Feature/FeatureSetTest.php b/test/TableGateway/Feature/FeatureSetTest.php index bfc6271c17..a24918a683 100644 --- a/test/TableGateway/Feature/FeatureSetTest.php +++ b/test/TableGateway/Feature/FeatureSetTest.php @@ -1,8 +1,10 @@ setTableGateway($tableGatewayMock); $this->assertInstanceOf('Zend\Db\TableGateway\Feature\FeatureSet', $featureSet->addFeature($feature)); @@ -76,22 +80,74 @@ public function testAddFeatureThatFeatureHasTableGatewayButFeatureSetDoesNotHave $feature = new MetadataFeature($metadataMock); $feature->setTableGateway($tableGatewayMock); - $featureSet = new FeatureSet; + $featureSet = new FeatureSet(); $this->assertInstanceOf('Zend\Db\TableGateway\Feature\FeatureSet', $featureSet->addFeature($feature)); } + /** + * @covers Zend\Db\TableGateway\Feature\FeatureSet::getFeatureByClassName + */ + public function testGetSingleFeatureByClassName() + { + $featureMock = $this->getMock(AbstractFeature::class); + + $featureSet = new FeatureSet(); + $featureSet->addFeature($featureMock); + + $this->assertInstanceOf( + get_class($featureMock), + $featureSet->getFeatureByClassName(get_class($featureMock)), + 'When only one feature of its type is added to FeatureSet, getFeatureByClassName() should return that single instance' + ); + } + + /** + * @covers Zend\Db\TableGateway\Feature\FeatureSet::getFeatureByClassName + */ + public function testGetAllFeaturesOfSameTypeByClassName() + { + $featureMock1 = $this->getMock(AbstractFeature::class); + $featureMock2 = $this->getMock(AbstractFeature::class); + + $featureSet = new FeatureSet(); + $featureSet->addFeature($featureMock1); + $featureSet->addFeature($featureMock2); + + $features = $featureSet->getFeatureByClassName(get_class($featureMock1)); + + $this->assertTrue(is_array($features), 'When multiple features of same type are added, they all should be return in array'); + + $this->assertInstanceOf(get_class($featureMock1), $features[0]); + $this->assertInstanceOf(get_class($featureMock2), $features[1]); + } + + /** + * @covers Zend\Db\TableGateway\Feature\FeatureSet::getFeatureByClassName + */ + public function testGetFeatureByClassNameReturnsFalseIfNotAdded() + { + $featureMock = $this->getMock(AbstractFeature::class); + + $featureSet = new FeatureSet(); + + $this->assertFalse( + $featureSet->getFeatureByClassName(get_class($featureMock)), + 'Requesting unregistered feature should return false' + ); + } + /** * @covers Zend\Db\TableGateway\Feature\FeatureSet::canCallMagicCall */ public function testCanCallMagicCallReturnsTrueForAddedMethodOfAddedFeature() { $feature = new SequenceFeature('id', 'table_sequence'); - $featureSet = new FeatureSet; + $featureSet = new FeatureSet(); $featureSet->addFeature($feature); $this->assertTrue( $featureSet->canCallMagicCall('lastSequenceId'), - "Should have been able to call lastSequenceId from the Sequence Feature" + 'Should have been able to call lastSequenceId from the Sequence Feature' ); } @@ -101,12 +157,12 @@ public function testCanCallMagicCallReturnsTrueForAddedMethodOfAddedFeature() public function testCanCallMagicCallReturnsFalseForAddedMethodOfAddedFeature() { $feature = new SequenceFeature('id', 'table_sequence'); - $featureSet = new FeatureSet; + $featureSet = new FeatureSet(); $featureSet->addFeature($feature); $this->assertFalse( $featureSet->canCallMagicCall('postInitialize'), - "Should have been able to call postInitialize from the MetaData Feature" + 'Should have been able to call postInitialize from the MetaData Feature' ); } @@ -115,7 +171,7 @@ public function testCanCallMagicCallReturnsFalseForAddedMethodOfAddedFeature() */ public function testCanCallMagicCallReturnsFalseWhenNoFeaturesHaveBeenAdded() { - $featureSet = new FeatureSet; + $featureSet = new FeatureSet(); $this->assertFalse( $featureSet->canCallMagicCall('lastSequenceId') ); @@ -126,8 +182,30 @@ public function testCanCallMagicCallReturnsFalseWhenNoFeaturesHaveBeenAdded() */ public function testCallMagicCallSucceedsForValidMethodOfAddedFeature() { - $sequenceName = 'table_sequence'; + $featureSet = new FeatureSet(); + $featureSet->addFeature($this->getMockSequence('table_name', 'sequence_name', 1)); + $this->assertEquals(1, $featureSet->callMagicCall('lastSequenceId', null)); + } + + /** + * @covers Zend\Db\TableGateway\Feature\FeatureSet::callMagicCall + */ + public function testCallMagicMethodAllSimilarFeaturesUntilNotNull() + { + $featureSet = new FeatureSet(); + + $featureSet->addFeature($this->getMockSequence('col_1', 'seq_1', 1)); + $featureSet->addFeature($this->getMockSequence('col_2', 'seq_2', 2)); + $featureSet->addFeature($this->getMockSequence('col_3', 'seq_3', 3)); + + $result = $featureSet->callMagicCall('lastSequenceId', 'col_2'); + + $this->assertEquals(2, $result); + } + // FeatureSet uses method_exists which does not work on mock objects. Therefore, need a real object. + private function getMockSequence($columnName, $sequenceName, $expectedCurrVal) + { $platformMock = $this->getMock('Zend\Db\Adapter\Platform\Postgresql'); $platformMock->expects($this->any()) ->method('getName')->will($this->returnValue('PostgreSQL')); @@ -135,37 +213,34 @@ public function testCallMagicCallSucceedsForValidMethodOfAddedFeature() $resultMock = $this->getMock('Zend\Db\Adapter\Driver\Pgsql\Result'); $resultMock->expects($this->any()) ->method('current') - ->will($this->returnValue(['currval' => 1])); + ->will($this->returnValue(['currval' => $expectedCurrVal])); $statementMock = $this->getMock('Zend\Db\Adapter\Driver\StatementInterface'); $statementMock->expects($this->any()) - ->method('prepare') - ->with('SELECT CURRVAL(\'' . $sequenceName . '\')'); + ->method('prepare'); $statementMock->expects($this->any()) ->method('execute') ->will($this->returnValue($resultMock)); - $adapterMock = $this->getMockBuilder('Zend\Db\Adapter\Adapter') - ->disableOriginalConstructor() - ->getMock(); + $adapterMock = $this->getMock('Zend\Db\Adapter\Adapter', ['getPlatform', 'createStatement'], [], '', false); + $adapterMock->expects($this->any()) + ->method('getPlatform') + ->will($this->returnValue(new TrustingPostgresqlPlatform())); $adapterMock->expects($this->any()) ->method('getPlatform')->will($this->returnValue($platformMock)); $adapterMock->expects($this->any()) ->method('createStatement')->will($this->returnValue($statementMock)); - $tableGatewayMock = $this->getMockBuilder('Zend\Db\TableGateway\AbstractTableGateway') - ->disableOriginalConstructor() - ->getMock(); + $tableGatewayMock = $this->getMockForAbstractClass('Zend\Db\TableGateway\TableGateway', ['table', $adapterMock], '', true); $reflectionClass = new ReflectionClass('Zend\Db\TableGateway\AbstractTableGateway'); $reflectionProperty = $reflectionClass->getProperty('adapter'); $reflectionProperty->setAccessible(true); $reflectionProperty->setValue($tableGatewayMock, $adapterMock); - $feature = new SequenceFeature('id', 'table_sequence'); + $feature = new SequenceFeature($columnName, $sequenceName); $feature->setTableGateway($tableGatewayMock); - $featureSet = new FeatureSet; - $featureSet->addFeature($feature); - $this->assertEquals(1, $featureSet->callMagicCall('lastSequenceId', null)); + + return $feature; } } diff --git a/test/TableGateway/Feature/SequenceFeatureTest.php b/test/TableGateway/Feature/SequenceFeatureTest.php index b3edc59c33..8436e72831 100644 --- a/test/TableGateway/Feature/SequenceFeatureTest.php +++ b/test/TableGateway/Feature/SequenceFeatureTest.php @@ -1,8 +1,10 @@ feature = new SequenceFeature($this->primaryKeyField, $this->sequenceName); + $adapter = $this->getMock('Zend\Db\Adapter\Adapter', ['getPlatform', 'createStatement'], [], '', false); + $adapter->expects($this->any()) + ->method('getPlatform') + ->will($this->returnValue($platform)); + + $this->tableGateway = $this->getMockForAbstractClass('Zend\Db\TableGateway\TableGateway', [$tableIdentifier, $adapter], '', true); + + $sequence = new SequenceFeature('serial_column', $sequenceName); + $sequence->setTableGateway($this->tableGateway); + $this->assertEquals($expectedSequenceName, $sequence->getSequenceName()); + } + + public function identifierProvider() + { + return [ + [new TrustingPostgresqlPlatform(), + 'table', null, '"table_serial_column_seq"', ], + [new TrustingPostgresqlPlatform(), + ['schema', 'table'], null, '"schema"."table_serial_column_seq"', ], + [new TrustingPostgresqlPlatform(), + new TableIdentifier('table', 'schema'), null, '"schema"."table_serial_column_seq"', ], + [new TrustingPostgresqlPlatform(), + new TableIdentifier('table', 'schema'), ['schema', 'sequence_name'], '"schema"."sequence_name"', ], + ]; + } + + public function testSequenceNameQueriedWhenTooLong() + { + $adapter = $this->getMock('Zend\Db\Adapter\Adapter', ['getPlatform', 'createStatement'], [], '', false); + $adapter->expects($this->any()) + ->method('getPlatform') + ->will($this->returnValue(new TrustingPostgresqlPlatform())); + $result = $this->getMockForAbstractClass('Zend\Db\Adapter\Driver\ResultInterface', [], '', false, true, true, ['current']); + $result->expects($this->any()) + ->method('current') + ->will($this->returnValue(['pg_get_serial_sequence' => 'table_name_column_very_long_name_causing_postgresql_to_trun_seq'])); + $statement = $this->getMockForAbstractClass('Zend\Db\Adapter\Driver\StatementInterface', [], '', false, true, true, ['prepare', 'execute']); + $statement->expects($this->any()) + ->method('execute') + ->with(['table' => 'table_name', 'column' => 'column_very_long_name_causing_postgresql_to_truncate']) + ->will($this->returnValue($result)); + $statement->expects($this->any()) + ->method('prepare') + ->with('SELECT pg_get_serial_sequence(:table, :column)'); + $adapter->expects($this->once()) + ->method('createStatement') + ->will($this->returnValue($statement)); + $this->tableGateway = $this->getMockForAbstractClass('Zend\Db\TableGateway\TableGateway', ['table_name', $adapter], '', true); + + $sequence = new SequenceFeature('column_very_long_name_causing_postgresql_to_truncate'); + $sequence->setTableGateway($this->tableGateway); + + $this->assertEquals('table_name_column_very_long_name_causing_postgresql_to_trun_seq', $sequence->getSequenceName()); + } + + /** + * Sequences for SERIAL columns start with no name which eventually gets filled. + * Ensure null value is replaced with actual on first call + * so that repeated calls to getSequenceName() do not make extra database calls (for long name case). + * + * Also test do not try to generate when name is manually supplied in constructor. + */ + public function testCacheSequenceName() + { + $adapter = $this->getMock('Zend\Db\Adapter\Adapter', ['getPlatform', 'createStatement'], [], '', false); + $adapter->expects($this->any()) + ->method('getPlatform') + ->will($this->returnValue(new TrustingPostgresqlPlatform())); + $result = $this->getMockForAbstractClass('Zend\Db\Adapter\Driver\ResultInterface', [], '', false, true, true, ['current']); + $result->expects($this->once()) + ->method('current') + ->will($this->returnValue(['pg_get_serial_sequence' => 'table_name_column_very_long_name_causing_postgresql_to_trun_seq'])); + $statement = $this->getMockForAbstractClass('Zend\Db\Adapter\Driver\StatementInterface', [], '', false, true, true, ['prepare', 'execute']); + $statement->expects($this->once()) + ->method('execute') + ->with(['table' => 'table_name', 'column' => 'column_very_long_name_causing_postgresql_to_truncate']) + ->will($this->returnValue($result)); + $statement->expects($this->once()) + ->method('prepare') + ->with('SELECT pg_get_serial_sequence(:table, :column)'); + $adapter->expects($this->once()) + ->method('createStatement') + ->will($this->returnValue($statement)); + $this->tableGateway = $this->getMockForAbstractClass('Zend\Db\TableGateway\TableGateway', ['table_name', $adapter], '', true); + + $sequence = new SequenceFeature('column_very_long_name_causing_postgresql_to_truncate'); + $sequence->setTableGateway($this->tableGateway); + + $this->assertEquals('table_name_column_very_long_name_causing_postgresql_to_trun_seq', $sequence->getSequenceName()); + $this->assertEquals('table_name_column_very_long_name_causing_postgresql_to_trun_seq', $sequence->getSequenceName()); } /** * @dataProvider nextSequenceIdProvider */ - public function testNextSequenceId($platformName, $statementSql) + public function testNextSequenceIdByPlatform($platform, $statementSql, $statementParameter) { - $platform = $this->getMockForAbstractClass('Zend\Db\Adapter\Platform\PlatformInterface', ['getName']); - $platform->expects($this->any()) - ->method('getName') - ->will($this->returnValue($platformName)); - $platform->expects($this->any()) - ->method('quoteIdentifier') - ->will($this->returnValue($this->sequenceName)); $adapter = $this->getMock('Zend\Db\Adapter\Adapter', ['getPlatform', 'createStatement'], [], '', false); $adapter->expects($this->any()) ->method('getPlatform') @@ -54,6 +141,7 @@ public function testNextSequenceId($platformName, $statementSql) $statement = $this->getMockForAbstractClass('Zend\Db\Adapter\Driver\StatementInterface', [], '', false, true, true, ['prepare', 'execute']); $statement->expects($this->any()) ->method('execute') + ->with($statementParameter) ->will($this->returnValue($result)); $statement->expects($this->any()) ->method('prepare') @@ -62,13 +150,62 @@ public function testNextSequenceId($platformName, $statementSql) ->method('createStatement') ->will($this->returnValue($statement)); $this->tableGateway = $this->getMockForAbstractClass('Zend\Db\TableGateway\TableGateway', ['table', $adapter], '', true); - $this->feature->setTableGateway($this->tableGateway); - $this->feature->nextSequenceId(); + + $feature = new SequenceFeature($this->primaryKeyField, $this->sequenceName); + $feature->setTableGateway($this->tableGateway); + $feature->nextSequenceId(); } public function nextSequenceIdProvider() { - return [['PostgreSQL', 'SELECT NEXTVAL(\'"' . $this->sequenceName . '"\')'], - ['Oracle', 'SELECT ' . $this->sequenceName . '.NEXTVAL as "nextval" FROM dual']]; + return [ + [new TrustingPostgresqlPlatform(), 'SELECT NEXTVAL( :sequence_name )', ['sequence_name' => $this->sequenceName]], + [new TrustingOraclePlatform(), 'SELECT "'.$this->sequenceName.'".NEXTVAL as "nextval" FROM dual', []], + ]; + } + /** + * @dataProvider lastSequenceIdProvider + */ + public function testLastSequenceIdByPlatform($platform, $statementSql, $statementParameter) + { + $adapter = $this->getMock('Zend\Db\Adapter\Adapter', ['getPlatform', 'createStatement'], [], '', false); + $adapter->expects($this->any()) + ->method('getPlatform') + ->will($this->returnValue($platform)); + $result = $this->getMockForAbstractClass('Zend\Db\Adapter\Driver\ResultInterface', [], '', false, true, true, ['current']); + $result->expects($this->any()) + ->method('current') + ->will($this->returnValue(['currval' => 1])); + $statement = $this->getMockForAbstractClass('Zend\Db\Adapter\Driver\StatementInterface', [], '', false, true, true, ['prepare', 'execute']); + $statement->expects($this->any()) + ->method('execute') + ->with($statementParameter) + ->will($this->returnValue($result)); + $statement->expects($this->any()) + ->method('prepare') + ->with($statementSql); + $adapter->expects($this->once()) + ->method('createStatement') + ->will($this->returnValue($statement)); + $this->tableGateway = $this->getMockForAbstractClass('Zend\Db\TableGateway\TableGateway', ['table', $adapter], '', true); + + $feature = new SequenceFeature($this->primaryKeyField, $this->sequenceName); + $feature->setTableGateway($this->tableGateway); + $feature->lastSequenceId(); + } + + public function lastSequenceIdProvider() + { + return [ + [new TrustingPostgresqlPlatform(), 'SELECT CURRVAL( :sequence_name )', ['sequence_name' => $this->sequenceName]], + [new TrustingOraclePlatform(), 'SELECT "'.$this->sequenceName.'".CURRVAL as "currval" FROM dual', []], + ]; + } + + public function testDoNotReactToDifferentColumnName() + { + $sequence1 = new SequenceFeature('col_1', 'seq_1'); + $this->assertEquals($sequence1->lastSequenceId('col_2'), null, 'Sequence should not react to foreign column name'); + $this->assertEquals($sequence1->nextSequenceId('col_2'), null, 'Sequence should not react to foreign column name'); } } diff --git a/test/TestAsset/TrustingPostgresqlPlatform.php b/test/TestAsset/TrustingPostgresqlPlatform.php new file mode 100644 index 0000000000..c9308cc466 --- /dev/null +++ b/test/TestAsset/TrustingPostgresqlPlatform.php @@ -0,0 +1,22 @@ +quoteTrustedValue($value); + } +}