From: Eileen McNaughton Date: Tue, 19 Apr 2022 19:30:53 +0000 (+1200) Subject: Start to use user_job to track import X-Git-Url: https://vcs.fsf.org/?a=commitdiff_plain;h=7b057b666202a6548d8900e407a0e17d5ffd174a;p=civicrm-core.git Start to use user_job to track import --- diff --git a/CRM/Contact/Import/Form/DataSource.php b/CRM/Contact/Import/Form/DataSource.php index 0377f23fdf..d4da8d74ed 100644 --- a/CRM/Contact/Import/Form/DataSource.php +++ b/CRM/Contact/Import/Form/DataSource.php @@ -74,7 +74,7 @@ class CRM_Contact_Import_Form_DataSource extends CRM_Import_Forms { public function buildQuickForm() { $this->assign('urlPath', 'civicrm/import/datasource'); - $this->assign('urlPathVar', 'snippet=4'); + $this->assign('urlPathVar', 'snippet=4&user_job_id=' . $this->get('user_job_id')); $this->add('select', 'dataSource', ts('Data Source'), $this->getDataSources(), TRUE, ['onchange' => 'buildDataSourceFormBlock(this.value);'] @@ -168,10 +168,16 @@ class CRM_Contact_Import_Form_DataSource extends CRM_Import_Forms { * Call the DataSource's postProcess method. * * @throws \CRM_Core_Exception + * @throws \API_Exception */ public function postProcess() { $this->controller->resetPage('MapField'); - + if (!$this->getUserJobID()) { + $this->createUserJob(); + } + else { + $this->updateUserJobMetadata('submitted_values', $this->getSubmittedValues()); + } // Setup the params array $this->_params = $this->controller->exportValues($this->_name); @@ -208,6 +214,7 @@ class CRM_Contact_Import_Form_DataSource extends CRM_Import_Forms { $parser = new CRM_Contact_Import_Parser_Contact($mapper); $parser->setMaxLinesToProcess(100); + $parser->setUserJobID($this->getUserJobID()); $parser->run($importTableName, $mapper, CRM_Import_Parser::MODE_MAPFIELD, @@ -234,12 +241,12 @@ class CRM_Contact_Import_Form_DataSource extends CRM_Import_Forms { * @throws \CRM_Core_Exception */ private function instantiateDataSource(): void { - $dataSourceName = $this->getDataSourceClassName(); - $dataSource = new $dataSourceName(); + $dataSource = $this->getDataSourceObject(); // Get the PEAR::DB object $dao = new CRM_Core_DAO(); $db = $dao->getDatabaseConnection(); $dataSource->postProcess($this->_params, $db, $this); + $this->updateUserJobMetadata('DataSource', $dataSource->getDataSourceMetadata()); } /** diff --git a/CRM/Core/BAO/UserJob.php b/CRM/Core/BAO/UserJob.php index 5eaf41979e..193d7186e0 100644 --- a/CRM/Core/BAO/UserJob.php +++ b/CRM/Core/BAO/UserJob.php @@ -50,11 +50,16 @@ class CRM_Core_BAO_UserJob extends CRM_Core_DAO_UserJob { ], [ 'id' => 2, + 'name' => 'draft', + 'label' => ts('Draft'), + ], + [ + 'id' => 3, 'name' => 'scheduled', 'label' => ts('Scheduled'), ], [ - 'id' => 3, + 'id' => 4, 'name' => 'in_progress', 'label' => ts('In Progress'), ], diff --git a/CRM/Import/DataSource.php b/CRM/Import/DataSource.php index e7a8cc8459..c5a4a44cef 100644 --- a/CRM/Import/DataSource.php +++ b/CRM/Import/DataSource.php @@ -15,12 +15,118 @@ * @copyright CiviCRM LLC https://civicrm.org/licensing */ +use Civi\Api4\UserJob; + /** * This class defines the DataSource interface but must be subclassed to be * useful. */ abstract class CRM_Import_DataSource { + /** + * Class constructor. + * + * @param int|null $userJobID + */ + public function __construct(int $userJobID = NULL) { + if ($userJobID) { + $this->setUserJobID($userJobID); + } + } + + /** + * Form fields declared for this datasource. + * + * @var string[] + */ + protected $submittableFields = []; + + /** + * User job id. + * + * This is the primary key of the civicrm_user_job table which is used to + * track the import. + * + * @var int + */ + protected $userJobID; + + /** + * @return int|null + */ + public function getUserJobID(): ?int { + return $this->userJobID; + } + + /** + * Set user job ID. + * + * @param int $userJobID + */ + public function setUserJobID(int $userJobID): void { + $this->userJobID = $userJobID; + } + + /** + * User job details. + * + * This is the relevant row from civicrm_user_job. + * + * @var array + */ + protected $userJob; + + /** + * Get User Job. + * + * API call to retrieve the userJob row. + * + * @return array + * + * @throws \API_Exception + */ + protected function getUserJob(): array { + if (!$this->userJob) { + $this->userJob = UserJob::get() + ->addWhere('id', '=', $this->getUserJobID()) + ->execute() + ->first(); + } + return $this->userJob; + } + + /** + * Generated metadata relating to the the datasource. + * + * This is values that are computed within the DataSource class and + * which are stored in the userJob metadata in the DataSource key - eg. + * + * ['table_name' => $] + * + * Will be in the user_job.metadata field encoded into the json like + * + * `{'DataSource' : ['table_name' => $], 'submitted_values' : .....}` + * + * @var array + */ + protected $dataSourceMetadata = []; + + /** + * @return array + */ + public function getDataSourceMetadata(): array { + return $this->dataSourceMetadata; + } + + /** + * Get the fields declared for this datasource. + * + * @return string[] + */ + public function getSubmittableFields(): array { + return $this->submittableFields; + } + /** * Provides information about the data source. * diff --git a/CRM/Import/DataSource/CSV.php b/CRM/Import/DataSource/CSV.php index 9e0c830877..c91ca15a88 100644 --- a/CRM/Import/DataSource/CSV.php +++ b/CRM/Import/DataSource/CSV.php @@ -18,6 +18,13 @@ class CRM_Import_DataSource_CSV extends CRM_Import_DataSource { const NUM_ROWS_TO_INSERT = 100; + /** + * Form fields declared for this datasource. + * + * @var string[] + */ + protected $submittableFields = ['skipColumnHeader', 'uploadField']; + /** * Provides information about the data source. * @@ -88,8 +95,12 @@ class CRM_Import_DataSource_CSV extends CRM_Import_DataSource { CRM_Utils_Array::value('fieldSeparator', $params, ',') ); - $form->set('originalColHeader', CRM_Utils_Array::value('original_col_header', $result)); + $form->set('originalColHeader', CRM_Utils_Array::value('column_headers', $result)); $form->set('importTableName', $result['import_table_name']); + $this->dataSourceMetadata = [ + 'table_name' => $result['import_table_name'], + 'column_headers' => $result['column_headers'] ?? NULL, + ]; } /** @@ -135,7 +146,7 @@ class CRM_Import_DataSource_CSV extends CRM_Import_DataSource { // create the column names from the CSV header or as col_0, col_1, etc. if ($headers) { //need to get original headers. - $result['original_col_header'] = $firstrow; + $result['column_headers'] = $firstrow; $strtolower = function_exists('mb_strtolower') ? 'mb_strtolower' : 'strtolower'; $columns = array_map($strtolower, $firstrow); @@ -242,7 +253,6 @@ class CRM_Import_DataSource_CSV extends CRM_Import_DataSource { //get the import tmp table name. $result['import_table_name'] = $tableName; - return $result; } diff --git a/CRM/Import/DataSource/SQL.php b/CRM/Import/DataSource/SQL.php index 633b3dd05a..2e712f4317 100644 --- a/CRM/Import/DataSource/SQL.php +++ b/CRM/Import/DataSource/SQL.php @@ -16,6 +16,13 @@ */ class CRM_Import_DataSource_SQL extends CRM_Import_DataSource { + /** + * Form fields declared for this datasource. + * + * @var string[] + */ + protected $submittableFields = ['sqlQuery']; + /** * Provides information about the data source. * @@ -90,6 +97,9 @@ class CRM_Import_DataSource_SQL extends CRM_Import_DataSource { ); $form->set('importTableName', $importJob->getTableName()); + $this->dataSourceMetadata = [ + 'table_name' => $importJob->getTableName(), + ]; } } diff --git a/CRM/Import/Form/DataSourceConfig.php b/CRM/Import/Form/DataSourceConfig.php index 93b34e739f..e5ddfc870b 100644 --- a/CRM/Import/Form/DataSourceConfig.php +++ b/CRM/Import/Form/DataSourceConfig.php @@ -29,13 +29,52 @@ class CRM_Import_Form_DataSourceConfig extends CRM_Import_Forms { $dataSourcePath = explode('_', $this->getDataSourceClassName()); $templateFile = 'CRM/Contact/Import/Form/' . $dataSourcePath[3] . '.tpl'; $this->assign('dataSourceFormTemplateFile', $templateFile ?? NULL); + if (CRM_Utils_Request::retrieveValue('user_job_id', 'Integer')) { + $this->setUserJobID(CRM_Utils_Request::retrieveValue('user_job_id', 'Integer')); + } } /** * Build the form object. + * + * @throws \CRM_Core_Exception */ public function buildQuickForm(): void { $this->buildDataSourceFields(); } + /** + * Set defaults. + * + * @return array + * + * @throws \API_Exception + * @throws \CRM_Core_Exception + */ + public function setDefaultValues() { + $defaults = []; + if ($this->userJobID) { + foreach ($this->getDataSourceFields() as $fieldName) { + $defaults[$fieldName] = $this->getSubmittedValue($fieldName); + } + } + return $defaults; + } + + /** + * Get the submitted value, as saved in the user job. + * + * This form is not in the same flow as the DataSource but + * the value we want is saved to the userJob so load it from there. + * + * @param string $fieldName + * + * @return mixed|null + * @throws \API_Exception + */ + public function getSubmittedValue(string $fieldName) { + $userJob = $this->getUserJob(); + return $userJob['metadata']['submitted_values'][$fieldName]; + } + } diff --git a/CRM/Import/Forms.php b/CRM/Import/Forms.php index b7d15b55d0..be62a5200e 100644 --- a/CRM/Import/Forms.php +++ b/CRM/Import/Forms.php @@ -15,27 +15,116 @@ * @copyright CiviCRM LLC https://civicrm.org/licensing */ +use Civi\Api4\UserJob; + /** * This class helps the forms within the import flow access submitted & parsed values. */ class CRM_Import_Forms extends CRM_Core_Form { /** - * Get the submitted value, accessing it from whatever form in the flow it is submitted on. + * User job id. + * + * This is the primary key of the civicrm_user_job table which is used to + * track the import. + * + * @var int + */ + protected $userJobID; + + /** + * @return int|null + */ + public function getUserJobID(): ?int { + if (!$this->userJobID && $this->get('user_job_id')) { + $this->userJobID = $this->get('user_job_id'); + } + return $this->userJobID; + } + + /** + * Set user job ID. + * + * @param int $userJobID + */ + public function setUserJobID(int $userJobID): void { + $this->userJobID = $userJobID; + // This set allows other forms in the flow ot use $this->get('user_job_id'). + $this->set('user_job_id', $userJobID); + } + + /** + * User job details. + * + * This is the relevant row from civicrm_user_job. + * + * @var array + */ + protected $userJob; + + /** + * Get User Job. + * + * API call to retrieve the userJob row. + * + * @return array + * + * @throws \API_Exception + */ + protected function getUserJob(): array { + if (!$this->userJob) { + $this->userJob = UserJob::get() + ->addWhere('id', '=', $this->getUserJobID()) + ->execute() + ->first(); + } + return $this->userJob; + } + + /** + * Get submitted values stored in the user job. + * + * @return array + * @throws \API_Exception + */ + protected function getUserJobSubmittedValues(): array { + return $this->getUserJob()['metadata']['submitted_values']; + } + + /** + * Fields that may be submitted on any form in the flow. + * + * @var string[] + */ + protected $submittableFields = [ + // Skip column header is actually a field that would be added from the + // datasource - but currently only in contact, it is always there for + // other imports, ditto uploadFile. + 'skipColumnHeader' => 'DataSource', + 'fieldSeparator' => 'DataSource', + 'uploadFile' => 'DataSource', + 'contactType' => 'DataSource', + 'dateFormats' => 'DataSource', + 'savedMapping' => 'DataSource', + 'dataSource' => 'DataSource', + ]; + + /** + * Get the submitted value, accessing it from whatever form in the flow it is + * submitted on. + * * @param string $fieldName * * @return mixed|null + * @throws \CRM_Core_Exception */ public function getSubmittedValue(string $fieldName) { - $mappedValues = [ - 'skipColumnHeader' => 'DataSource', - 'fieldSeparator' => 'DataSource', - 'uploadFile' => 'DataSource', - 'contactType' => 'DataSource', - 'dateFormats' => 'DataSource', - 'savedMapping' => 'DataSource', - 'dataSource' => 'DataSource', - ]; + if ($fieldName === 'dataSource') { + // Hard-coded handling for DataSource as it affects the contents of + // getSubmittableFields and can cause a loop. + return $this->controller->exportValue('DataSource', 'dataSource'); + } + $mappedValues = $this->getSubmittableFields(); if (array_key_exists($fieldName, $mappedValues)) { return $this->controller->exportValue($mappedValues[$fieldName], $fieldName); } @@ -43,6 +132,19 @@ class CRM_Import_Forms extends CRM_Core_Form { } + /** + * Get values submitted on any form in the multi-page import flow. + * + * @return array + */ + public function getSubmittedValues(): array { + $values = []; + foreach (array_keys($this->getSubmittableFields()) as $key) { + $values[$key] = $this->getSubmittedValue($key); + } + return $values; + } + /** * Get the available datasource. * @@ -123,11 +225,45 @@ class CRM_Import_Forms extends CRM_Core_Form { * @throws \CRM_Core_Exception */ protected function buildDataSourceFields(): void { + $dataSourceClass = $this->getDataSourceObject(); + if ($dataSourceClass) { + $dataSourceClass->buildQuickForm($this); + } + } + + /** + * Get the relevant datasource object. + * + * @return \CRM_Import_DataSource|null + * + * @throws \CRM_Core_Exception + */ + protected function getDataSourceObject(): ?CRM_Import_DataSource { + $className = $this->getDataSourceClassName(); + if ($className) { + /* @var CRM_Import_DataSource $dataSource */ + return new $className($this->getUserJobID()); + } + return NULL; + } + + /** + * Allow the datasource class to add fields. + * + * This is called as a snippet in DataSourceConfig and + * also from DataSource::buildForm to add the fields such + * that quick form picks them up. + * + * @throws \CRM_Core_Exception + */ + protected function getDataSourceFields(): array { $className = $this->getDataSourceClassName(); if ($className) { + /* @var CRM_Import_DataSource $dataSourceClass */ $dataSourceClass = new $className(); - $dataSourceClass->buildQuickForm($this); + return $dataSourceClass->getSubmittableFields(); } + return []; } /** @@ -139,4 +275,62 @@ class CRM_Import_Forms extends CRM_Core_Form { return 'CRM_Import_DataSource_CSV'; } + /** + * Get the fields that can be submitted in the Import form flow. + * + * These could be on any form in the flow & are accessed the same way from + * all forms. + * + * @return string[] + * @throws \CRM_Core_Exception + */ + protected function getSubmittableFields(): array { + $dataSourceFields = array_fill_keys($this->getDataSourceFields(), 'DataSource'); + return array_merge($this->submittableFields, $dataSourceFields); + } + + /** + * Create a user job to track the import. + * + * @return int + * + * @throws \API_Exception + */ + protected function createUserJob(): int { + $id = UserJob::create(FALSE) + ->setValues([ + 'created_id' => CRM_Core_Session::getLoggedInContactID(), + 'type_id:name' => 'contact_import', + 'status_id:name' => 'draft', + // This suggests the data could be cleaned up after this. + 'expires_date' => '+ 1 week', + 'metadata' => [ + 'submitted_values' => $this->getSubmittedValues(), + ], + ]) + ->execute() + ->first()['id']; + $this->setUserJobID($id); + return $id; + } + + /** + * @param string $key + * @param array $data + * + * @throws \API_Exception + * @throws \Civi\API\Exception\UnauthorizedException + */ + protected function updateUserJobMetadata(string $key, array $data): void { + $metaData = array_merge( + $this->getUserJob()['metadata'], + [$key => $data] + ); + UserJob::update(FALSE) + ->addWhere('id', '=', $this->getUserJobID()) + ->setValues(['metadata' => $metaData]) + ->execute(); + $this->userJob['metadata'] = $metaData; + } + } diff --git a/CRM/Import/Parser.php b/CRM/Import/Parser.php index c88dc02f8a..bd74b492b4 100644 --- a/CRM/Import/Parser.php +++ b/CRM/Import/Parser.php @@ -9,6 +9,8 @@ +--------------------------------------------------------------------+ */ +use Civi\Api4\UserJob; + /** * * @package CRM @@ -41,6 +43,48 @@ abstract class CRM_Import_Parser { const CONTACT_INDIVIDUAL = 1, CONTACT_HOUSEHOLD = 2, CONTACT_ORGANIZATION = 4; + /** + * User job id. + * + * This is the primary key of the civicrm_user_job table which is used to + * track the import. + * + * @var int + */ + protected $userJobID; + + /** + * @return int|null + */ + public function getUserJobID(): ?int { + return $this->userJobID; + } + + /** + * Set user job ID. + * + * @param int $userJobID + */ + public function setUserJobID(int $userJobID): void { + $this->userJobID = $userJobID; + } + + /** + * Get User Job. + * + * API call to retrieve the userJob row. + * + * @return array + * + * @throws \API_Exception + */ + protected function getUserJob(): array { + return UserJob::get() + ->addWhere('id', '=', $this->getUserJobID()) + ->execute() + ->first(); + } + /** * Total number of non empty lines * @var int diff --git a/templates/CRM/Contact/Import/Form/DataSource.tpl b/templates/CRM/Contact/Import/Form/DataSource.tpl index deaef96954..58e4d29f85 100644 --- a/templates/CRM/Contact/Import/Form/DataSource.tpl +++ b/templates/CRM/Contact/Import/Form/DataSource.tpl @@ -98,7 +98,7 @@ function buildDataSourceFormBlock(dataSource) { - var dataUrl = {/literal}"{crmURL p=$urlPath h=0 q=$urlPathVar}"{literal}; + var dataUrl = {/literal}"{crmURL p=$urlPath h=0 q=$urlPathVar|smarty:nodefaults}"{literal}; if (!dataSource ) { var dataSource = cj("#dataSource").val(); diff --git a/tests/phpunit/CRM/Contact/Import/Form/DataSourceTest.php b/tests/phpunit/CRM/Contact/Import/Form/DataSourceTest.php index 68056c7e2d..2d096185ab 100644 --- a/tests/phpunit/CRM/Contact/Import/Form/DataSourceTest.php +++ b/tests/phpunit/CRM/Contact/Import/Form/DataSourceTest.php @@ -14,6 +14,8 @@ * File for the CRM_Contact_Import_Form_DataSourceTest class. */ +use Civi\Api4\UserJob; + /** * Test contact import datasource. * @@ -22,12 +24,20 @@ */ class CRM_Contact_Import_Form_DataSourceTest extends CiviUnitTestCase { + /** + * Post test cleanup. + */ + public function tearDown(): void { + $this->quickCleanup(['civicrm_user_job']); + parent::tearDown(); + } + /** * Test the form loads without error / notice and mappings are assigned. * * (Added in conjunction with fixed noting on mapping assignment). */ - public function testBuildForm() { + public function testBuildForm(): void { $this->callAPISuccess('Mapping', 'create', ['name' => 'Well dressed ducks', 'mapping_type_id' => 'Import Contact']); $form = $this->getFormObject('CRM_Contact_Import_Form_DataSource'); $form->buildQuickForm(); @@ -35,16 +45,89 @@ class CRM_Contact_Import_Form_DataSourceTest extends CiviUnitTestCase { } /** - * Check for (lack of) sql errors on sql import post process. + * Test sql and csv data-sources load and save user jobs. + * + * This test mimics a scenario where the form is submitted more than once + * and the user_job is updated to reflect the new data source. + * + * @throws \API_Exception + * @throws \CRM_Core_Exception + * @throws \Civi\API\Exception\UnauthorizedException */ - public function testSQLSource() { + public function testDataSources(): void { + $this->createLoggedInUser(); $this->callAPISuccess('Mapping', 'create', ['name' => 'Well dressed ducks', 'mapping_type_id' => 'Import Contact']); - /** @var CRM_Import_DataSource_SQL $form */ - $form = $this->getFormObject('CRM_Import_DataSource_SQL', [], 'SQL'); - $coreForm = $this->getFormObject('CRM_Core_Form'); - $db = NULL; - $params = ['sqlQuery' => 'SELECT 1 as id']; - $form->postProcess($params, $db, $coreForm); + + $sqlFormValues = [ + 'dataSource' => 'CRM_Import_DataSource_SQL', + 'sqlQuery' => 'SELECT "bob" as first_name FROM civicrm_option_value LIMIT 5', + ]; + $form = $this->submitDataSourceForm($sqlFormValues); + $userJobID = $form->getUserJobID(); + // Load the user job, using TRUE so permissions apply. + $userJob = UserJob::get(TRUE) + ->addWhere('id', '=', $userJobID) + ->addSelect('metadata') + ->execute()->first(); + // Submitted values should be stored in the user job. + // There are some null values in the submitted_values array - we can + // filter these out as we have not passed in all possible values. + $this->assertEquals($sqlFormValues, array_filter($userJob['metadata']['submitted_values'])); + + // The user job holds the name of the table - which should have 5 rows of bob. + $this->assertNotEmpty($userJob['metadata']['DataSource']['table_name']); + $sqlTableName = $userJob['metadata']['DataSource']['table_name']; + $this->assertEquals(5, CRM_Core_DAO::singleValueQuery( + 'SELECT count(*) FROM ' . $sqlTableName + . " WHERE first_name = 'Bob'" + )); + + // Now we imitate the scenario where the user goes back and + // re-submits the form selecting the csv datasource. + $csvFormValues = [ + 'dataSource' => 'CRM_Import_DataSource_CSV', + 'skipColumnHeader' => 1, + 'uploadFile' => [ + 'name' => __DIR__ . '/data/yogi.csv', + 'type' => 'text/csv', + ], + ]; + // Mimic form re-submission with new values. + $_SESSION['_' . $form->controller->_name . '_container']['values']['DataSource'] = $csvFormValues; + $form->buildForm(); + $form->postProcess(); + // The user job id should not have changed. + $this->assertEquals($userJobID, $form->getUserJobID()); + + $userJob = UserJob::get(TRUE) + ->addWhere('id', '=', $form->getUserJobID()) + ->addSelect('metadata') + ->execute()->first(); + // Submitted values should be updated in the user job. + $this->assertEquals($csvFormValues, array_filter($userJob['metadata']['submitted_values'])); + + $csvTableName = $userJob['metadata']['DataSource']['table_name']; + $this->assertEquals(1, CRM_Core_DAO::singleValueQuery( + 'SELECT count(*) FROM ' . $csvTableName + . " WHERE first_name = 'yogi'" + )); + } + + /** + * Submit the dataSoure form with the provided form values. + * + * @param array $sqlFormValues + * + * @return CRM_Contact_Import_Form_DataSource + * @throws \API_Exception + * @throws \CRM_Core_Exception + */ + private function submitDataSourceForm(array $sqlFormValues): CRM_Contact_Import_Form_DataSource { + /** @var CRM_Contact_Import_Form_DataSource $form */ + $form = $this->getFormObject('CRM_Contact_Import_Form_DataSource', $sqlFormValues); + $form->buildForm(); + $form->postProcess(); + return $form; } } diff --git a/tests/phpunit/CRM/Contact/Import/Form/data/yogi.csv b/tests/phpunit/CRM/Contact/Import/Form/data/yogi.csv new file mode 100644 index 0000000000..a8ccf3f832 --- /dev/null +++ b/tests/phpunit/CRM/Contact/Import/Form/data/yogi.csv @@ -0,0 +1,2 @@ +Last Name,email,First Name +BearĀ ,yogi@yellowstone.parkĀ ,Yogi diff --git a/tests/phpunit/CRM/Import/DataSource/CsvTest.php b/tests/phpunit/CRM/Import/DataSource/CsvTest.php index 41626941ee..a549a1bd07 100644 --- a/tests/phpunit/CRM/Import/DataSource/CsvTest.php +++ b/tests/phpunit/CRM/Import/DataSource/CsvTest.php @@ -14,6 +14,14 @@ */ class CRM_Import_DataSource_CsvTest extends CiviUnitTestCase { + /** + * Prepare for tests. + */ + public function setUp(): void { + $this->createLoggedInUser(); + parent::setUp(); + } + /** * Test the to csv function. *