From 12a369939595443262a9473d681b5a9371da869f Mon Sep 17 00:00:00 2001 From: eileen Date: Tue, 5 Jan 2016 23:03:47 +1300 Subject: [PATCH] CRM-17772 refactor main export processing function This is a first refactor on the exportComponents function with the goal being to a) provide a (small) start on getting testing going b) move some of the php level processing out of the main ->fetch loop. Since this is iterated once per row it has a significant impact on large exports. When doing profiling I noticed that the munge function was taking quite a bit of time. I was investigating a reported increased slowness on upgrade & that had been introduced since the previous version and the iteration happens has to help. Although I still had to leave some true old uglinesses & introduced some new ones I think this is on the right track - ie. in general we should make the array match the fields in the query unless there is good reaon for extra handling. This conclusion was also the driver for the test I added. Don't even ask about that relationship block! But if we got rid of that we could structure as 'dao_query_field_name' => . --- CRM/Export/BAO/Export.php | 393 +++++++++++--------- tests/phpunit/CRM/Export/BAO/ExportTest.php | 132 ++++++- 2 files changed, 353 insertions(+), 172 deletions(-) diff --git a/CRM/Export/BAO/Export.php b/CRM/Export/BAO/Export.php index ac502166c7..6c60720981 100644 --- a/CRM/Export/BAO/Export.php +++ b/CRM/Export/BAO/Export.php @@ -302,11 +302,13 @@ class CRM_Export_BAO_Export { $queryOperator = 'AND' ) { - $headerRows = $returnProperties = array(); + $returnProperties = array(); $paymentFields = $selectedPaymentFields = FALSE; $relationField = NULL; $phoneTypes = CRM_Core_PseudoConstant::get('CRM_Core_DAO_Phone', 'phone_type_id'); + // Warning - this imProviders var is used in a somewhat fragile way - don't rename it + // without manually testing the export of IM provider still works. $imProviders = CRM_Core_PseudoConstant::get('CRM_Core_DAO_IM', 'provider_id'); $contactRelationshipTypes = CRM_Contact_BAO_Relationship::getContactRelationshipType( NULL, @@ -743,7 +745,7 @@ INSERT INTO {$componentTable} SELECT distinct gc.contact_id FROM civicrm_group_c $nullContributionDetails = array_fill_keys(array_keys($paymentHeaders), NULL); } - $componentDetails = $headerRows = $sqlColumns = array(); + $componentDetails = array(); $setHeader = TRUE; $rowCount = self::EXPORT_ROW_COUNT; @@ -755,12 +757,8 @@ INSERT INTO {$componentTable} SELECT distinct gc.contact_id FROM civicrm_group_c // for CRM-3157 purposes $i18n = CRM_Core_I18n::singleton(); - $outputColumns = array(); - //@todo - it would be clearer to start defining output columns earlier in this function rather than stick with return properties until this point - // as the array is not actually 'returnProperties' after the sql query is formed - making the alterations to it confusing - foreach ($returnProperties as $key => $value) { - $outputColumns[$key] = $value; - } + list($outputColumns, $headerRows, $sqlColumns, $metadata) = self::getExportStructureArrays($returnProperties, $query, $phoneTypes, $imProviders, $contactRelationshipTypes, $relationQuery, $selectedPaymentFields); + while (1) { $limitQuery = "{$queryString} LIMIT {$offset}, {$rowCount}"; $dao = CRM_Core_DAO::executeQuery($limitQuery); @@ -788,125 +786,6 @@ INSERT INTO {$componentTable} SELECT distinct gc.contact_id FROM civicrm_group_c //first loop through output columns so that we return what is required, and in same order. $relationshipField = 0; foreach ($outputColumns as $field => $value) { - //we should set header only once - if ($setHeader) { - $sqlDone = FALSE; - // Split campaign into 2 fields for id and title - if (substr($field, -14) == 'campaign_title') { - $headerRows[] = ts('Campaign Title'); - } - elseif (substr($field, -11) == 'campaign_id') { - $headerRows[] = ts('Campaign ID'); - } - elseif (isset($query->_fields[$field]['title'])) { - $headerRows[] = $query->_fields[$field]['title']; - } - elseif ($field == 'phone_type_id') { - $headerRows[] = ts('Phone Type'); - } - elseif ($field == 'provider_id') { - $headerRows[] = ts('IM Service Provider'); - } - elseif (is_array($value) && $field == 'location') { - // fix header for location type case - foreach ($value as $ltype => $val) { - foreach (array_keys($val) as $fld) { - $type = explode('-', $fld); - $hdr = "{$ltype}-" . $query->_fields[$type[0]]['title']; - - if (!empty($type[1])) { - if (CRM_Utils_Array::value(0, $type) == 'phone') { - $hdr .= "-" . CRM_Utils_Array::value($type[1], $phoneTypes); - } - elseif (CRM_Utils_Array::value(0, $type) == 'im') { - $hdr .= "-" . CRM_Utils_Array::value($type[1], $imProviders); - } - } - $headerRows[] = $hdr; - self::sqlColumnDefn($query, $sqlColumns, $hdr); - } - $sqlDone = TRUE; - } - } - elseif (substr($field, 0, 5) == 'case_') { - if ($query->_fields['case'][$field]['title']) { - $headerRows[] = $query->_fields['case'][$field]['title']; - } - elseif ($query->_fields['activity'][$field]['title']) { - $headerRows[] = $query->_fields['activity'][$field]['title']; - } - } - elseif (array_key_exists($field, $contactRelationshipTypes)) { - $relName = $field; - foreach ($value as $relationField => $relationValue) { - // below block is same as primary block (duplicate) - if (isset($relationQuery[$field]->_fields[$relationField]['title'])) { - if ($relationQuery[$field]->_fields[$relationField]['name'] == 'name') { - $headerName = $field . '-' . $relationField; - } - else { - if ($relationField == 'current_employer') { - $headerName = $field . '-' . 'current_employer'; - } - else { - $headerName = $field . '-' . $relationQuery[$field]->_fields[$relationField]['name']; - } - } - - $headerRows[] = $headerName; - - self::sqlColumnDefn($query, $sqlColumns, $headerName); - } - elseif ($relationField == 'phone_type_id') { - $headerName = $field . '-' . 'Phone Type'; - $headerRows[] = $headerName; - self::sqlColumnDefn($query, $sqlColumns, $headerName); - } - elseif ($relationField == 'provider_id') { - $headerName = $field . '-' . 'Im Service Provider'; - $headerRows[] = $headerName; - self::sqlColumnDefn($query, $sqlColumns, $headerName); - } - elseif ($relationField == 'state_province_id') { - $headerName = $field . '-' . 'state_province_id'; - $headerRows[] = $headerName; - self::sqlColumnDefn($query, $sqlColumns, $headerName); - } - elseif (is_array($relationValue) && $relationField == 'location') { - // fix header for location type case - foreach ($relationValue as $ltype => $val) { - foreach (array_keys($val) as $fld) { - $type = explode('-', $fld); - - $hdr = "{$ltype}-" . $relationQuery[$field]->_fields[$type[0]]['title']; - - if (!empty($type[1])) { - if (CRM_Utils_Array::value(0, $type) == 'phone') { - $hdr .= "-" . CRM_Utils_Array::value($type[1], $phoneTypes); - } - elseif (CRM_Utils_Array::value(0, $type) == 'im') { - $hdr .= "-" . CRM_Utils_Array::value($type[1], $imProviders); - } - } - $headerName = $field . '-' . $hdr; - $headerRows[] = $headerName; - self::sqlColumnDefn($query, $sqlColumns, $headerName); - } - } - } - } - } - elseif ($selectedPaymentFields && array_key_exists($field, self::componentPaymentFields())) { - $headerRows[] = CRM_Utils_Array::value($field, self::componentPaymentFields()); - } - else { - $headerRows[] = $field; - } - - if (!$sqlDone) { - self::sqlColumnDefn($query, $sqlColumns, $field); - } - } // add im_provider to $dao object if ($field == 'im_provider' && property_exists($iterationDAO, 'provider_id')) { @@ -948,44 +827,6 @@ INSERT INTO {$componentTable} SELECT distinct gc.contact_id FROM civicrm_group_c elseif ($field == 'pledge_next_pay_amount') { $row[$field] = $iterationDAO->pledge_next_pay_amount + $iterationDAO->pledge_outstanding_amount; } - elseif (is_array($value) && $field == 'location') { - // fix header for location type case - foreach ($value as $ltype => $val) { - foreach (array_keys($val) as $fld) { - $type = explode('-', $fld); - $fldValue = "{$ltype}-" . $type[0]; - // CRM-14076 - fix label to work as the query object expects - // FIXME: We should not be using labels as keys! - $daoField = CRM_Utils_String::munge($ltype) . '-' . $type[0]; - - if (!empty($type[1])) { - $fldValue .= "-" . $type[1]; - $daoField .= "-" . $type[1]; - } - - // CRM-3157: localise country, region (both have ‘country’ context) and state_province (‘province’ context) - switch ($fld) { - case 'country': - case 'world_region': - $row[$fldValue] = $i18n->crm_translate($iterationDAO->$daoField, array('context' => 'country')); - break; - - case 'state_province': - $row[$fldValue] = $i18n->crm_translate($iterationDAO->$daoField, array('context' => 'province')); - break; - - case 'im_provider': - $imFieldvalue = $daoField . "-provider_id"; - $row[$fldValue] = CRM_Utils_Array::value($iterationDAO->$imFieldvalue, $imProviders); - break; - - default: - $row[$fldValue] = $iterationDAO->$daoField; - break; - } - } - } - } elseif (array_key_exists($field, $contactRelationshipTypes)) { $relDAO = CRM_Utils_Array::value($iterationDAO->contact_id, $allRelContactArray[$field]); $relationQuery[$field]->convertToPseudoNames($relDAO); @@ -1148,6 +989,27 @@ INSERT INTO {$componentTable} SELECT distinct gc.contact_id FROM civicrm_group_c break; default: + if (isset($metadata[$field])) { + // No I don't know why we do it this way & whether we could + // make better use of pseudoConstants. + if (!empty($metadata[$field]['context'])) { + $row[$field] = $i18n->crm_translate($fieldValue, $metadata[$field]); + break; + } + if (!empty($metadata[$field]['pseudoconstant'])) { + // This is not our normal syntax for pseudoconstants but I am a bit loath to + // call an external function until sure it is not increasing php processing given this + // may be iterated 100,000 times & we already have the $imProvider var loaded. + // That can be next refactor... + // Yes - definitely feeling hatred for this bit of code - I know you will beat me up over it's awfulness + // but I have to reach a stable point.... + $varName = $metadata[$field]['pseudoconstant']['var']; + $labels = $$varName; + $row[$field] = $labels[$fieldValue]; + break; + } + + } $row[$field] = $fieldValue; break; } @@ -1172,6 +1034,8 @@ INSERT INTO {$componentTable} SELECT distinct gc.contact_id FROM civicrm_group_c // add payment headers if required if ($addPaymentHeader && $paymentFields) { + // @todo rather than do this for every single row do it before the loop starts. + // where other header definitions take place. $headerRows = array_merge($headerRows, $paymentHeaders); foreach (array_keys($paymentHeaders) as $paymentHdr) { self::sqlColumnDefn($query, $sqlColumns, $paymentHdr); @@ -1237,11 +1101,6 @@ INSERT INTO {$componentTable} SELECT distinct gc.contact_id FROM civicrm_group_c self::mergeSameHousehold($exportTempTable, $headerRows, $sqlColumns, $relationKeyHOH); } - // fix the headers for rows with relationship type - if (!empty($relName)) { - self::manipulateHeaderRows($headerRows, $contactRelationshipTypes); - } - // if postalMailing option is checked, exclude contacts who are deceased, have // "Do not mail" privacy setting, or have no street address if (isset($exportParams['postal_mailing_export']['postal_mailing_export']) && @@ -1406,7 +1265,7 @@ INSERT INTO {$componentTable} SELECT distinct gc.contact_id FROM civicrm_group_c * @param $sqlColumns * @param $field */ - public static function sqlColumnDefn(&$query, &$sqlColumns, $field) { + public static function sqlColumnDefn($query, &$sqlColumns, $field) { if (substr($field, -4) == '_a_b' || substr($field, -4) == '_b_a') { return; } @@ -2143,4 +2002,196 @@ WHERE {$whereClause}"; return $componentPaymentFields; } + /** + * Set the definition for the header rows and sql columns based on the field to output. + * + * @param string $field + * @param array $headerRows + * @param array $sqlColumns + * Columns to go in the temp table. + * @param CRM_Contact_BAO_Query $query + * @param array|string $value + * @param array $phoneTypes + * @param array $imProviders + * @param array $contactRelationshipTypes + * @param string $relationQuery + * @param array $selectedPaymentFields + * @return array + */ + public static function setHeaderRows($field, $headerRows, $sqlColumns, $query, $value, $phoneTypes, $imProviders, $contactRelationshipTypes, $relationQuery, $selectedPaymentFields) { + + // Split campaign into 2 fields for id and title + if (substr($field, -14) == 'campaign_title') { + $headerRows[] = ts('Campaign Title'); + } + elseif (substr($field, -11) == 'campaign_id') { + $headerRows[] = ts('Campaign ID'); + } + elseif (isset($query->_fields[$field]['title'])) { + $headerRows[] = $query->_fields[$field]['title']; + } + elseif ($field == 'phone_type_id') { + $headerRows[] = ts('Phone Type'); + } + elseif ($field == 'provider_id') { + $headerRows[] = ts('IM Service Provider'); + } + elseif (substr($field, 0, 5) == 'case_') { + if ($query->_fields['case'][$field]['title']) { + $headerRows[] = $query->_fields['case'][$field]['title']; + } + elseif ($query->_fields['activity'][$field]['title']) { + $headerRows[] = $query->_fields['activity'][$field]['title']; + } + } + elseif (array_key_exists($field, $contactRelationshipTypes)) { + self::manipulateHeaderRows($headerRows, $contactRelationshipTypes); + foreach ($value as $relationField => $relationValue) { + // below block is same as primary block (duplicate) + if (isset($relationQuery[$field]->_fields[$relationField]['title'])) { + if ($relationQuery[$field]->_fields[$relationField]['name'] == 'name') { + $headerName = $field . '-' . $relationField; + } + else { + if ($relationField == 'current_employer') { + $headerName = $field . '-' . 'current_employer'; + } + else { + $headerName = $field . '-' . $relationQuery[$field]->_fields[$relationField]['name']; + } + } + + $headerRows[] = $headerName; + + self::sqlColumnDefn($query, $sqlColumns, $headerName); + } + elseif ($relationField == 'phone_type_id') { + $headerName = $field . '-' . 'Phone Type'; + $headerRows[] = $headerName; + self::sqlColumnDefn($query, $sqlColumns, $headerName); + } + elseif ($relationField == 'provider_id') { + $headerName = $field . '-' . 'Im Service Provider'; + $headerRows[] = $headerName; + self::sqlColumnDefn($query, $sqlColumns, $headerName); + } + elseif ($relationField == 'state_province_id') { + $headerName = $field . '-' . 'state_province_id'; + $headerRows[] = $headerName; + self::sqlColumnDefn($query, $sqlColumns, $headerName); + } + elseif (is_array($relationValue) && $relationField == 'location') { + // fix header for location type case + foreach ($relationValue as $ltype => $val) { + foreach (array_keys($val) as $fld) { + $type = explode('-', $fld); + + $hdr = "{$ltype}-" . $relationQuery[$field]->_fields[$type[0]]['title']; + + if (!empty($type[1])) { + if (CRM_Utils_Array::value(0, $type) == 'phone') { + $hdr .= "-" . CRM_Utils_Array::value($type[1], $phoneTypes); + } + elseif (CRM_Utils_Array::value(0, $type) == 'im') { + $hdr .= "-" . CRM_Utils_Array::value($type[1], $imProviders); + } + } + $headerName = $field . '-' . $hdr; + $headerRows[] = $headerName; + self::sqlColumnDefn($query, $sqlColumns, $headerName); + } + } + } + } + } + elseif ($selectedPaymentFields && array_key_exists($field, self::componentPaymentFields())) { + $headerRows[] = CRM_Utils_Array::value($field, self::componentPaymentFields()); + } + else { + $headerRows[] = $field; + } + + self::sqlColumnDefn($query, $sqlColumns, $field); + + return array($headerRows, $sqlColumns); + } + + /** + * Get the various arrays that we use to structure our output. + * + * The extraction of these has been moved to a separate function for clarity and so that + * tests can be added - in particular on the $outputHeaders array. + * + * However it still feels a bit like something that I'm too polite to write down and this should be seen + * as a step on the refactoring path rather than how it should be. + * + * @param array $returnProperties + * @param CRM_Contact_BAO_Contact $query + * @param array $phoneTypes + * @param array $imProviders + * @param array $contactRelationshipTypes + * @param string $relationQuery + * @param array $selectedPaymentFields + * @return array + * - outputColumns Array of columns to be exported. The values don't matter but the key must match the + * alias for the field generated by BAO_Query object. + * - headerRows Array of the column header strings to put in the csv header - non-associative. + * - sqlColumns Array of column names for the temp table. Not too sure why outputColumns can't be used here. + * - metadata Array of fields with specific parameters to pass to the translate function or another hacky nasty solution + * I'm too embarassed to discuss here. + * The keys need + * - to match the outputColumns keys (yes, the fact we ignore the output columns values & then pass another array with values + * we could use does suggest further refactors. However, you future improver, do remember that every check you do + * in the main DAO loop is done once per row & that coule be 100,000 times.) + * Finally a pop quiz: We need the translate context because we use a function other than ts() - is this because + * - a) the function used is more efficient or + * - b) this code is old & outdated. Submit your answers to circular bin or better + * yet find a way to comment them for posterity. + */ + public static function getExportStructureArrays($returnProperties, $query, $phoneTypes, $imProviders, $contactRelationshipTypes, $relationQuery, $selectedPaymentFields) { + $metadata = $headerRows = $outputColumns = $sqlColumns = array(); + + foreach ($returnProperties as $key => $value) { + if ($key != 'location' || !is_array($value)) { + $outputColumns[$key] = $value; + list($headerRows, $sqlColumns) = self::setHeaderRows($key, $headerRows, $sqlColumns, $query, $value, $phoneTypes, $imProviders, $contactRelationshipTypes, $relationQuery, $selectedPaymentFields); + } + else { + foreach ($value as $locationType => $locationFields) { + foreach (array_keys($locationFields) as $locationFieldName) { + $type = explode('-', $locationFieldName); + + $actualDBFieldName = $type[0]; + $outputFieldName = $locationType . '-' . $query->_fields[$actualDBFieldName]['title']; + $daoFieldName = CRM_Utils_String::munge($locationType) . '-' . $actualDBFieldName; + + if (!empty($type[1])) { + $daoFieldName .= "-" . $type[1]; + if ($actualDBFieldName == 'phone') { + $outputFieldName .= "-" . CRM_Utils_Array::value($type[1], $phoneTypes); + } + elseif ($actualDBFieldName == 'im') { + $outputFieldName .= "-" . CRM_Utils_Array::value($type[1], $imProviders); + } + } + if ($type[0] == 'im_provider') { + // Warning: shame inducing hack. + $metadata[$daoFieldName]['pseudoconstant']['var'] = 'imProviders'; + } + self::sqlColumnDefn($query, $sqlColumns, $outputFieldName); + list($headerRows, $sqlColumns) = self::setHeaderRows($outputFieldName, $headerRows, $sqlColumns, $query, $value, $phoneTypes, $imProviders, $contactRelationshipTypes, $relationQuery, $selectedPaymentFields); + if ($actualDBFieldName == 'country' || $actualDBFieldName == 'world_region') { + $metadata[$daoFieldName] = array('context' => 'country'); + } + if ($actualDBFieldName == 'state_province') { + $metadata[$daoFieldName] = array('context' => 'province'); + } + $outputColumns[$daoFieldName] = TRUE; + } + } + } + } + return array($outputColumns, $headerRows, $sqlColumns, $metadata); + } + } diff --git a/tests/phpunit/CRM/Export/BAO/ExportTest.php b/tests/phpunit/CRM/Export/BAO/ExportTest.php index af08969160..fd4bcaff63 100644 --- a/tests/phpunit/CRM/Export/BAO/ExportTest.php +++ b/tests/phpunit/CRM/Export/BAO/ExportTest.php @@ -6,10 +6,24 @@ require_once 'CiviTest/CiviUnitTestCase.php'; */ class CRM_Export_BAO_ExportTest extends CiviUnitTestCase { + /** + * Contact IDs created for testing. + * + * @var array + */ + protected $contactIDs = array(); + + /** + * Contribution IDs created for testing. + * + * @var array + */ + protected $contributionIDs = array(); + /** * Basic test to ensure the exportComponents function completes without error. */ - function testExportComponentsNull() { + public function testExportComponentsNull() { CRM_Export_BAO_Export::exportComponents( TRUE, array(), @@ -28,4 +42,120 @@ class CRM_Export_BAO_ExportTest extends CiviUnitTestCase { ) ); } + + /** + * Basic test to ensure the exportComponents function can export selected fields for contribution. + */ + public function testExportComponentsContribution() { + $this->setUpContributionExportData(); + $selectedFields = array( + array('Individual', 'first_name'), + array('Individual', 'last_name'), + array('Contribution', 'receive_date'), + array('Contribution', 'contribution_source'), + array('Individual', 'street_address', 1), + array('Individual', 'city', 1), + array('Individual', 'country', 1), + array('Individual', 'email', 1), + array('Contribution', 'trxn_id'), + ); + CRM_Export_BAO_Export::exportComponents( + TRUE, + $this->contributionIDs, + array(), + 'receive_date desc', + $selectedFields, + NULL, + CRM_Export_Form_Select::CONTRIBUTE_EXPORT, + 'civicrm_contribution.id IN ( ' . implode(',', $this->contributionIDs) . ')', + NULL, + FALSE, + FALSE, + array( + 'exportOption' => CRM_Export_Form_Select::CONTRIBUTE_EXPORT, + 'suppress_csv_for_testing' => TRUE, + ) + ); + } + /** + * Test the function that extracts the arrays used to structure the output. + * + * The keys in the output fields array should by matched by field aliases in the sql query (with + * exceptions of course - currently country is one - although maybe a future refactor can change that!). + * + * We are trying to move towards simpler processing in the per row iteration as that may be + * repeated 100,000 times and in general we should simply be able to match the query fields to + * our expected rows & do a little pseudoconstant mapping. + */ + public function testGetExportStructureArrays() { + // This is how return properties are formatted internally within the function for passing to the BAO query. + $returnProperties = array( + 'first_name' => 1, + 'last_name' => 1, + 'receive_date' => 1, + 'contribution_source' => 1, + 'location' => array( + 'Home' => array( + 'street_address' => 1, + 'city' => 1, + 'country' => 1, + 'email' => 1, + 'im-1' => 1, + 'im_provider' => 1, + 'phone-1' => 1, + ), + ), + 'phone' => 1, + 'trxn_id' => 1, + 'contribution_id' => 1, + ); + $phoneTypes = CRM_Core_PseudoConstant::get('CRM_Core_DAO_Phone', 'phone_type_id'); + $imProviders = CRM_Core_PseudoConstant::get('CRM_Core_DAO_IM', 'provider_id'); + $contactRelationshipTypes = CRM_Contact_BAO_Relationship::getContactRelationshipType( + NULL, + NULL, + NULL, + NULL, + TRUE, + 'name', + FALSE + ); + + $query = new CRM_Contact_BAO_Query(array(), $returnProperties, NULL, + FALSE, FALSE, CRM_Contact_BAO_Query::MODE_CONTRIBUTE, + FALSE, TRUE, TRUE, NULL, 'AND' + ); + + list($select) = $query->query(); + $pattern = '/as `?([^`,]*)/'; + $queryFieldAliases = array(); + preg_match_all($pattern, $select, $queryFieldAliases, PREG_PATTERN_ORDER); + + list($outputFields) = CRM_Export_BAO_Export::getExportStructureArrays($returnProperties, $query, $phoneTypes, $imProviders, $contactRelationshipTypes, '', array()); + foreach (array_keys($outputFields) as $fieldAlias) { + if ($fieldAlias == 'Home-country') { + $this->assertTrue(in_array($fieldAlias . '_id', $queryFieldAliases[1]), 'Country is subject to some funky translate so we make sure country id is present'); + } + else { + $this->assertTrue(in_array($fieldAlias, $queryFieldAliases[1]), 'looking for field ' . $fieldAlias . ' in generaly the alias fields need to match the outputfields'); + } + } + + } + + /** + * Set up some data for us to do testing on. + */ + public function setUpContributionExportData() { + $this->setUpContactExportData(); + $this->contributionIDs[] = $this->contributionCreate(array('contact_id' => $this->contactIDs[0])); + } + + /** + * Set up some data for us to do testing on. + */ + public function setUpContactExportData() { + $this->contactIDs[] = $this->individualCreate(); + } + } -- 2.25.1