@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator

Convert complex query subclasses to use internal cursors

Summary:
Depends on D20292. Ref T13259. This converts the rest of the `getPagingValueMap()` callsites to operate on internal cursors instead.

These are pretty one-off for the most part, so I'll annotate them inline.

Test Plan:
- Grouped tasks by project, sorted by title, paged through them, saw consistent outcomes.
- Queried edges with "edge.search", paged through them using the "after" cursor.
- Poked around the other stuff without catching any brokenness.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13259

Differential Revision: https://secure.phabricator.com/D20293

+295 -190
+18 -7
src/applications/almanac/query/AlmanacInterfaceQuery.php
··· 78 78 return $interfaces; 79 79 } 80 80 81 + protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) { 82 + $select = parent::buildSelectClauseParts($conn); 83 + 84 + if ($this->shouldJoinDeviceTable()) { 85 + $select[] = qsprintf($conn, 'device.name'); 86 + } 87 + 88 + return $select; 89 + } 90 + 81 91 protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { 82 92 $where = parent::buildWhereClauseParts($conn); 83 93 ··· 186 196 ); 187 197 } 188 198 189 - protected function getPagingValueMap($cursor, array $keys) { 190 - $interface = $this->loadCursorObject($cursor); 199 + protected function newPagingMapFromCursorObject( 200 + PhabricatorQueryCursor $cursor, 201 + array $keys) { 191 202 192 - $map = array( 193 - 'id' => $interface->getID(), 194 - 'name' => $interface->getDevice()->getName(), 203 + $interface = $cursor->getObject(); 204 + 205 + return array( 206 + 'id' => (int)$interface->getID(), 207 + 'name' => $cursor->getRawRowProperty('device.name'), 195 208 ); 196 - 197 - return $map; 198 209 } 199 210 200 211 }
+13 -9
src/applications/feed/query/PhabricatorFeedQuery.php
··· 147 147 ); 148 148 } 149 149 150 - protected function getPagingValueMap($cursor, array $keys) { 151 - return array( 152 - 'key' => $cursor, 153 - ); 150 + protected function applyExternalCursorConstraintsToQuery( 151 + PhabricatorCursorPagedPolicyAwareQuery $subquery, 152 + $cursor) { 153 + $subquery->withChronologicalKeys(array($cursor)); 154 154 } 155 155 156 - protected function getResultCursor($item) { 157 - if ($item instanceof PhabricatorFeedStory) { 158 - return $item->getChronologicalKey(); 159 - } 160 - return $item['chronologicalKey']; 156 + protected function newExternalCursorStringForResult($object) { 157 + return $object->getChronologicalKey(); 158 + } 159 + 160 + protected function newPagingMapFromPartialObject($object) { 161 + // This query is unusual, and the "object" is a raw result row. 162 + return array( 163 + 'key' => $object['chronologicalKey'], 164 + ); 161 165 } 162 166 163 167 protected function getPrimaryTableAlias() {
+105 -33
src/applications/maniphest/query/ManiphestTaskQuery.php
··· 27 27 private $closedEpochMax; 28 28 private $closerPHIDs; 29 29 private $columnPHIDs; 30 + private $specificGroupByProjectPHID; 30 31 31 32 private $status = 'status-any'; 32 33 const STATUS_ANY = 'status-any'; ··· 224 225 225 226 public function withColumnPHIDs(array $column_phids) { 226 227 $this->columnPHIDs = $column_phids; 228 + return $this; 229 + } 230 + 231 + public function withSpecificGroupByProjectPHID($project_phid) { 232 + $this->specificGroupByProjectPHID = $project_phid; 227 233 return $this; 228 234 } 229 235 ··· 534 540 $select_phids); 535 541 } 536 542 543 + if ($this->specificGroupByProjectPHID !== null) { 544 + $where[] = qsprintf( 545 + $conn, 546 + 'projectGroupName.indexedObjectPHID = %s', 547 + $this->specificGroupByProjectPHID); 548 + } 549 + 537 550 return $where; 538 551 } 539 552 ··· 824 837 return array_mergev($phids); 825 838 } 826 839 827 - protected function getResultCursor($result) { 828 - $id = $result->getID(); 829 - 830 - if ($this->groupBy == self::GROUP_PROJECT) { 831 - return rtrim($id.'.'.$result->getGroupByProjectPHID(), '.'); 832 - } 833 - 834 - return $id; 835 - } 836 - 837 840 public function getBuiltinOrders() { 838 841 $orders = array( 839 842 'priority' => array( ··· 926 929 ); 927 930 } 928 931 929 - protected function getPagingValueMap($cursor, array $keys) { 930 - $cursor_parts = explode('.', $cursor, 2); 931 - $task_id = $cursor_parts[0]; 932 - $group_id = idx($cursor_parts, 1); 932 + protected function newPagingMapFromCursorObject( 933 + PhabricatorQueryCursor $cursor, 934 + array $keys) { 933 935 934 - $task = $this->loadCursorObject($task_id); 936 + $task = $cursor->getObject(); 935 937 936 938 $map = array( 937 - 'id' => $task->getID(), 938 - 'priority' => $task->getPriority(), 939 + 'id' => (int)$task->getID(), 940 + 'priority' => (int)$task->getPriority(), 939 941 'owner' => $task->getOwnerOrdering(), 940 942 'status' => $task->getStatus(), 941 943 'title' => $task->getTitle(), 942 - 'updated' => $task->getDateModified(), 944 + 'updated' => (int)$task->getDateModified(), 943 945 'closed' => $task->getClosedEpoch(), 944 946 ); 945 947 946 - foreach ($keys as $key) { 947 - switch ($key) { 948 - case 'project': 949 - $value = null; 950 - if ($group_id) { 951 - $paging_projects = id(new PhabricatorProjectQuery()) 952 - ->setViewer($this->getViewer()) 953 - ->withPHIDs(array($group_id)) 954 - ->execute(); 955 - if ($paging_projects) { 956 - $value = head($paging_projects)->getName(); 957 - } 958 - } 959 - $map[$key] = $value; 960 - break; 948 + if (isset($keys['project'])) { 949 + $value = null; 950 + 951 + $group_phid = $task->getGroupByProjectPHID(); 952 + if ($group_phid) { 953 + $paging_projects = id(new PhabricatorProjectQuery()) 954 + ->setViewer($this->getViewer()) 955 + ->withPHIDs(array($group_phid)) 956 + ->execute(); 957 + if ($paging_projects) { 958 + $value = head($paging_projects)->getName(); 959 + } 961 960 } 961 + 962 + $map['project'] = $value; 962 963 } 963 964 964 965 foreach ($keys as $key) { ··· 969 970 } 970 971 971 972 return $map; 973 + } 974 + 975 + protected function newExternalCursorStringForResult($object) { 976 + $id = $object->getID(); 977 + 978 + if ($this->groupBy == self::GROUP_PROJECT) { 979 + return rtrim($id.'.'.$object->getGroupByProjectPHID(), '.'); 980 + } 981 + 982 + return $id; 983 + } 984 + 985 + protected function newInternalCursorFromExternalCursor($cursor) { 986 + list($task_id, $group_phid) = $this->parseCursor($cursor); 987 + 988 + $cursor_object = parent::newInternalCursorFromExternalCursor($cursor); 989 + 990 + if ($group_phid !== null) { 991 + $project = id(new PhabricatorProjectQuery()) 992 + ->setViewer($this->getViewer()) 993 + ->withPHIDs(array($group_phid)) 994 + ->execute(); 995 + 996 + if (!$project) { 997 + $this->throwCursorException( 998 + pht( 999 + 'Group PHID ("%s") component of cursor ("%s") is not valid.', 1000 + $group_phid, 1001 + $cursor)); 1002 + } 1003 + 1004 + $cursor_object->getObject()->attachGroupByProjectPHID($group_phid); 1005 + } 1006 + 1007 + return $cursor_object; 1008 + } 1009 + 1010 + protected function applyExternalCursorConstraintsToQuery( 1011 + PhabricatorCursorPagedPolicyAwareQuery $subquery, 1012 + $cursor) { 1013 + list($task_id, $group_phid) = $this->parseCursor($cursor); 1014 + 1015 + $subquery->withIDs(array($task_id)); 1016 + 1017 + if ($group_phid) { 1018 + $subquery->setGroupBy(self::GROUP_PROJECT); 1019 + 1020 + // The subquery needs to return exactly one result. If a task is in 1021 + // several projects, the query may naturally return several results. 1022 + // Specify that we want only the particular instance of the task in 1023 + // the specified project. 1024 + $subquery->withSpecificGroupByProjectPHID($group_phid); 1025 + } 1026 + } 1027 + 1028 + 1029 + private function parseCursor($cursor) { 1030 + // Split a "123.PHID-PROJ-abcd" cursor into a "Task ID" part and a 1031 + // "Project PHID" part. 1032 + 1033 + $parts = explode('.', $cursor, 2); 1034 + 1035 + if (count($parts) < 2) { 1036 + $parts[] = null; 1037 + } 1038 + 1039 + if (!strlen($parts[1])) { 1040 + $parts[1] = null; 1041 + } 1042 + 1043 + return $parts; 972 1044 } 973 1045 974 1046 protected function getPrimaryTableAlias() {
+24 -20
src/applications/phriction/query/PhrictionDocumentQuery.php
··· 168 168 return $documents; 169 169 } 170 170 171 + protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) { 172 + $select = parent::buildSelectClauseParts($conn); 173 + 174 + if ($this->shouldJoinContentTable()) { 175 + $select[] = qsprintf($conn, 'c.title'); 176 + } 177 + 178 + return $select; 179 + } 180 + 171 181 protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) { 172 182 $joins = parent::buildJoinClauseParts($conn); 173 183 174 - if ($this->getOrderVector()->containsKey('updated')) { 184 + if ($this->shouldJoinContentTable()) { 175 185 $content_dao = new PhrictionContent(); 176 186 $joins[] = qsprintf( 177 187 $conn, ··· 180 190 } 181 191 182 192 return $joins; 193 + } 194 + 195 + private function shouldJoinContentTable() { 196 + return $this->getOrderVector()->containsKey('updated'); 183 197 } 184 198 185 199 protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { ··· 354 368 ); 355 369 } 356 370 357 - protected function getPagingValueMap($cursor, array $keys) { 358 - $document = $this->loadCursorObject($cursor); 371 + protected function newPagingMapFromCursorObject( 372 + PhabricatorQueryCursor $cursor, 373 + array $keys) { 374 + 375 + $document = $cursor->getObject(); 359 376 360 377 $map = array( 361 - 'id' => $document->getID(), 378 + 'id' => (int)$document->getID(), 362 379 'depth' => $document->getDepth(), 363 - 'updated' => $document->getEditedEpoch(), 380 + 'updated' => (int)$document->getEditedEpoch(), 364 381 ); 365 382 366 - foreach ($keys as $key) { 367 - switch ($key) { 368 - case 'title': 369 - $map[$key] = $document->getContent()->getTitle(); 370 - break; 371 - } 383 + if (isset($keys['title'])) { 384 + $map['title'] = $cursor->getRawRowProperty('c.title'); 372 385 } 373 386 374 387 return $map; 375 - } 376 - 377 - protected function willExecuteCursorQuery( 378 - PhabricatorCursorPagedPolicyAwareQuery $query) { 379 - $vector = $this->getOrderVector(); 380 - 381 - if ($vector->containsKey('title')) { 382 - $query->needContent(true); 383 - } 384 388 } 385 389 386 390 protected function getPrimaryTableAlias() {
+11 -36
src/applications/repository/query/PhabricatorRepositoryQuery.php
··· 442 442 ); 443 443 } 444 444 445 - protected function willExecuteCursorQuery( 446 - PhabricatorCursorPagedPolicyAwareQuery $query) { 447 - $vector = $this->getOrderVector(); 448 - 449 - if ($vector->containsKey('committed')) { 450 - $query->needMostRecentCommits(true); 451 - } 452 - 453 - if ($vector->containsKey('size')) { 454 - $query->needCommitCounts(true); 455 - } 456 - } 445 + protected function newPagingMapFromCursorObject( 446 + PhabricatorQueryCursor $cursor, 447 + array $keys) { 457 448 458 - protected function getPagingValueMap($cursor, array $keys) { 459 - $repository = $this->loadCursorObject($cursor); 449 + $repository = $cursor->getObject(); 460 450 461 451 $map = array( 462 - 'id' => $repository->getID(), 452 + 'id' => (int)$repository->getID(), 463 453 'callsign' => $repository->getCallsign(), 464 454 'name' => $repository->getName(), 465 455 ); 466 456 467 - foreach ($keys as $key) { 468 - switch ($key) { 469 - case 'committed': 470 - $commit = $repository->getMostRecentCommit(); 471 - if ($commit) { 472 - $map[$key] = $commit->getEpoch(); 473 - } else { 474 - $map[$key] = null; 475 - } 476 - break; 477 - case 'size': 478 - $count = $repository->getCommitCount(); 479 - if ($count) { 480 - $map[$key] = $count; 481 - } else { 482 - $map[$key] = null; 483 - } 484 - break; 485 - } 457 + if (isset($keys['committed'])) { 458 + $map['committed'] = $cursor->getRawRowProperty('epoch'); 459 + } 460 + 461 + if (isset($keys['size'])) { 462 + $map['size'] = $cursor->getRawRowProperty('size'); 486 463 } 487 464 488 465 return $map; ··· 490 467 491 468 protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) { 492 469 $parts = parent::buildSelectClauseParts($conn); 493 - 494 - $parts[] = qsprintf($conn, 'r.*'); 495 470 496 471 if ($this->shouldJoinSummaryTable()) { 497 472 $parts[] = qsprintf($conn, 's.*');
+17 -4
src/infrastructure/edges/conduit/PhabricatorEdgeObject.php
··· 8 8 private $src; 9 9 private $dst; 10 10 private $type; 11 + private $dateCreated; 12 + private $sequence; 11 13 12 14 public static function newFromRow(array $row) { 13 15 $edge = new self(); 14 16 15 - $edge->id = $row['id']; 16 - $edge->src = $row['src']; 17 - $edge->dst = $row['dst']; 18 - $edge->type = $row['type']; 17 + $edge->id = idx($row, 'id'); 18 + $edge->src = idx($row, 'src'); 19 + $edge->dst = idx($row, 'dst'); 20 + $edge->type = idx($row, 'type'); 21 + $edge->dateCreated = idx($row, 'dateCreated'); 22 + $edge->sequence = idx($row, 'seq'); 19 23 20 24 return $edge; 21 25 } ··· 39 43 public function getPHID() { 40 44 return null; 41 45 } 46 + 47 + public function getDateCreated() { 48 + return $this->dateCreated; 49 + } 50 + 51 + public function getSequence() { 52 + return $this->sequence; 53 + } 54 + 42 55 43 56 /* -( PhabricatorPolicyInterface )----------------------------------------- */ 44 57
+36 -17
src/infrastructure/edges/query/PhabricatorEdgeObjectQuery.php
··· 12 12 private $edgeTypes; 13 13 private $destinationPHIDs; 14 14 15 - 16 15 public function withSourcePHIDs(array $source_phids) { 17 16 $this->sourcePHIDs = $source_phids; 18 17 return $this; ··· 85 84 return $result; 86 85 } 87 86 88 - protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) { 89 - $parts = parent::buildSelectClauseParts($conn); 90 - 91 - // TODO: This is hacky, because we don't have real IDs on this table. 92 - $parts[] = qsprintf( 93 - $conn, 94 - 'CONCAT(dateCreated, %s, seq) AS id', 95 - '_'); 96 - 97 - return $parts; 98 - } 99 - 100 87 protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { 101 88 $parts = parent::buildWhereClauseParts($conn); 102 89 ··· 151 138 return array('dateCreated', 'sequence'); 152 139 } 153 140 154 - protected function getPagingValueMap($cursor, array $keys) { 155 - $parts = explode('_', $cursor); 141 + protected function newInternalCursorFromExternalCursor($cursor) { 142 + list($epoch, $sequence) = $this->parseCursor($cursor); 143 + 144 + // Instead of actually loading an edge, we're just making a fake edge 145 + // with the properties the cursor describes. 146 + 147 + $edge_object = PhabricatorEdgeObject::newFromRow( 148 + array( 149 + 'dateCreated' => $epoch, 150 + 'seq' => $sequence, 151 + )); 152 + 153 + return id(new PhabricatorQueryCursor()) 154 + ->setObject($edge_object); 155 + } 156 156 157 + protected function newPagingMapFromPartialObject($object) { 157 158 return array( 158 - 'dateCreated' => $parts[0], 159 - 'sequence' => $parts[1], 159 + 'dateCreated' => $object->getDateCreated(), 160 + 'sequence' => $object->getSequence(), 160 161 ); 162 + } 163 + 164 + protected function newExternalCursorStringForResult($object) { 165 + return sprintf( 166 + '%d_%d', 167 + $object->getDateCreated(), 168 + $object->getSequence()); 169 + } 170 + 171 + private function parseCursor($cursor) { 172 + if (!preg_match('/^\d+_\d+\z/', $cursor)) { 173 + $this->throwCursorException( 174 + pht( 175 + 'Expected edge cursor in the form "0123_6789", got "%s".', 176 + $cursor)); 177 + } 178 + 179 + return explode('_', $cursor); 161 180 } 162 181 163 182 }
+41 -64
src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
··· 19 19 private $externalCursorString; 20 20 private $internalCursorObject; 21 21 private $isQueryOrderReversed = false; 22 + private $rawCursorRow; 22 23 23 24 private $applicationSearchConstraints = array(); 24 25 private $internalPaging; ··· 53 54 } 54 55 55 56 protected function newInternalCursorFromExternalCursor($cursor) { 56 - return $this->newInternalCursorObjectFromID($cursor); 57 - } 58 - 59 - protected function newPagingMapFromCursorObject( 60 - PhabricatorQueryCursor $cursor, 61 - array $keys) { 62 - 63 - $object = $cursor->getObject(); 64 - 65 - return $this->newPagingMapFromPartialObject($object); 66 - } 67 - 68 - protected function newPagingMapFromPartialObject($object) { 69 - return array( 70 - 'id' => (int)$object->getID(), 71 - ); 72 - } 73 - 74 - final protected function newInternalCursorObjectFromID($id) { 75 57 $viewer = $this->getViewer(); 76 58 77 59 $query = newv(get_class($this), array()); 78 60 79 61 $query 80 62 ->setParentQuery($this) 81 - ->setViewer($viewer) 82 - ->withIDs(array((int)$id)); 63 + ->setViewer($viewer); 83 64 84 65 // We're copying our order vector to the subquery so that the subquery 85 66 // knows it should generate any supplemental information required by the ··· 96 77 // like a cursor this parent query would generate. 97 78 $query->setOrderVector($this->getOrderVector()); 98 79 80 + $this->applyExternalCursorConstraintsToQuery($query, $cursor); 81 + 99 82 // We're executing the subquery normally to make sure the viewer can 100 83 // actually see the object, and that it's a completely valid object which 101 84 // passes all filtering and policy checks. You aren't allowed to use an 102 85 // object you can't see as a cursor, since this can leak information. 103 86 $result = $query->executeOne(); 104 87 if (!$result) { 105 - // TODO: Raise a more tailored exception here and make the UI a little 106 - // prettier? 107 - throw new Exception( 88 + $this->throwCursorException( 108 89 pht( 109 90 'Cursor "%s" does not identify a valid object in query "%s".', 110 - $id, 91 + $cursor, 111 92 get_class($this))); 112 93 } 113 94 ··· 117 98 return $query->getInternalCursorObject(); 118 99 } 119 100 101 + final protected function throwCursorException($message) { 102 + // TODO: Raise a more tailored exception here and make the UI a little 103 + // prettier? 104 + throw new Exception($message); 105 + } 106 + 107 + protected function applyExternalCursorConstraintsToQuery( 108 + PhabricatorCursorPagedPolicyAwareQuery $subquery, 109 + $cursor) { 110 + $subquery->withIDs(array($cursor)); 111 + } 112 + 113 + protected function newPagingMapFromCursorObject( 114 + PhabricatorQueryCursor $cursor, 115 + array $keys) { 116 + 117 + $object = $cursor->getObject(); 118 + 119 + return $this->newPagingMapFromPartialObject($object); 120 + } 121 + 122 + protected function newPagingMapFromPartialObject($object) { 123 + return array( 124 + 'id' => (int)$object->getID(), 125 + ); 126 + } 127 + 128 + 120 129 final private function getExternalCursorStringForResult($object) { 121 130 $cursor = $this->newExternalCursorStringForResult($object); 122 131 ··· 215 224 $cursor = id(new PhabricatorQueryCursor()) 216 225 ->setObject(last($page)); 217 226 227 + if ($this->rawCursorRow) { 228 + $cursor->setRawRow($this->rawCursorRow); 229 + } 230 + 218 231 $this->setInternalCursorObject($cursor); 219 232 } 220 233 ··· 295 308 } 296 309 } 297 310 298 - return $rows; 299 - } 311 + $this->rawCursorRow = last($rows); 300 312 301 - /** 302 - * Get the viewer for making cursor paging queries. 303 - * 304 - * NOTE: You should ONLY use this viewer to load cursor objects while 305 - * building paging queries. 306 - * 307 - * Cursor paging can happen in two ways. First, the user can request a page 308 - * like `/stuff/?after=33`, which explicitly causes paging. Otherwise, we 309 - * can fall back to implicit paging if we filter some results out of a 310 - * result list because the user can't see them and need to go fetch some more 311 - * results to generate a large enough result list. 312 - * 313 - * In the first case, want to use the viewer's policies to load the object. 314 - * This prevents an attacker from figuring out information about an object 315 - * they can't see by executing queries like `/stuff/?after=33&order=name`, 316 - * which would otherwise give them a hint about the name of the object. 317 - * Generally, if a user can't see an object, they can't use it to page. 318 - * 319 - * In the second case, we need to load the object whether the user can see 320 - * it or not, because we need to examine new results. For example, if a user 321 - * loads `/stuff/` and we run a query for the first 100 items that they can 322 - * see, but the first 100 rows in the database aren't visible, we need to 323 - * be able to issue a query for the next 100 results. If we can't load the 324 - * cursor object, we'll fail or issue the same query over and over again. 325 - * So, generally, internal paging must bypass policy controls. 326 - * 327 - * This method returns the appropriate viewer, based on the context in which 328 - * the paging is occurring. 329 - * 330 - * @return PhabricatorUser Viewer for executing paging queries. 331 - */ 332 - final protected function getPagingViewer() { 333 - if ($this->internalPaging) { 334 - return PhabricatorUser::getOmnipotentUser(); 335 - } else { 336 - return $this->getViewer(); 337 - } 313 + return $rows; 338 314 } 339 315 340 316 final protected function buildLimitClause(AphrontDatabaseConnection $conn) { ··· 590 566 foreach ($vector as $order) { 591 567 $keys[] = $order->getOrderKey(); 592 568 } 569 + $keys = array_fuse($keys); 593 570 594 571 $value_map = $this->getPagingMapFromCursorObject( 595 572 $cursor_object,
+30
src/infrastructure/query/policy/PhabricatorQueryCursor.php
··· 4 4 extends Phobject { 5 5 6 6 private $object; 7 + private $rawRow; 7 8 8 9 public function setObject($object) { 9 10 $this->object = $object; ··· 12 13 13 14 public function getObject() { 14 15 return $this->object; 16 + } 17 + 18 + public function setRawRow(array $raw_row) { 19 + $this->rawRow = $raw_row; 20 + return $this; 21 + } 22 + 23 + public function getRawRow() { 24 + return $this->rawRow; 25 + } 26 + 27 + public function getRawRowProperty($key) { 28 + if (!is_array($this->rawRow)) { 29 + throw new Exception( 30 + pht( 31 + 'Caller is trying to "getRawRowProperty()" with key "%s", but this '. 32 + 'cursor has no raw row.', 33 + $key)); 34 + } 35 + 36 + if (!array_key_exists($key, $this->rawRow)) { 37 + throw new Exception( 38 + pht( 39 + 'Caller is trying to access raw row property "%s", but the row '. 40 + 'does not have this property.', 41 + $key)); 42 + } 43 + 44 + return $this->rawRow[$key]; 15 45 } 16 46 17 47 }