├── README.md
└── app
├── code
├── community
│ └── Bubble
│ │ └── Debug
│ │ ├── Model
│ │ └── Observer.php
│ │ └── etc
│ │ └── config.xml
└── local
│ └── Zend
│ └── Db
│ └── Adapter
│ └── Pdo
│ └── Abstract.php
└── etc
└── modules
└── Bubble_Debug.xml
/README.md:
--------------------------------------------------------------------------------
1 | ### Magento module to list rendered blocks tree and SQL queries
2 |
3 | 
4 |
5 | 
6 |
7 | ### Installation instructions
8 |
9 | Install with [modgit](https://github.com/jreinke/modgit):
10 |
11 | $ cd /path/to/magento
12 | $ modgit init
13 | $ modgit clone debug https://github.com/jreinke/magento-debug.git
14 |
15 | or download package manually [here](https://github.com/jreinke/magento-debug/archive/master.zip) and unzip in Magento root folder.
16 |
17 | Finally, clear cache.
18 |
19 | ### .gitignore (optional)
20 |
21 | Is is recommended to ignore this module files. Add this to your .gitignore file:
22 |
23 | app/code/community/Bubble/Debug/*
24 | app/code/local/Zend/Db/Adapter/Pdo/Abstract.php
25 | app/etc/modules/Bubble_Debug.xml
26 |
27 | ### Usage
28 |
29 | ##### Enable debugging for current page
30 |
31 | shop.example.com/apparel.html`?debug=1`
32 |
33 | ##### Enable debugging permanently
34 |
35 | shop.example.com/apparel.html`?debug=perm`
36 |
37 | ##### Disable permament debugging
38 |
39 | shop.example.com/apparel.html`?debug=0`
40 |
--------------------------------------------------------------------------------
/app/code/community/Bubble/Debug/Model/Observer.php:
--------------------------------------------------------------------------------
1 | array(), // rendered blocks start time
16 | 'blocks' => array(), // rendered blocks
17 | 'current_block' => null, // current rendered block
18 | 'sql' => array(), // sql queries
19 | );
20 |
21 | public function __construct()
22 | {
23 | $this->_request = Mage::app()->getRequest();
24 | if (($this->_request->getQuery('debug') || $this->_request->getCookie('debug'))
25 | && !$this->_request->isAjax() && !$this->_request->getPost())
26 | {
27 | $this->_debugEnabled = true;
28 | }
29 | }
30 |
31 | public function onSendResponseBefore(Varien_Event_Observer $observer)
32 | {
33 | // Reset permanent debugging if needed
34 | if ($this->_request->getQuery('debug') === '0') {
35 | Mage::getSingleton('core/cookie')->delete('debug');
36 | return;
37 | }
38 |
39 | if (!$this->isDebugEnabled()) {
40 | return;
41 | }
42 |
43 | // handling permanent debugging
44 | if ($this->_request->getQuery('debug') === 'perm') {
45 | Mage::getSingleton('core/cookie')->set('debug', 1);
46 | }
47 |
48 | $front = $observer->getEvent()->getFront();
49 | $html = $this->_getDebugHtml();
50 | $front->getResponse()->appendBody($html);
51 | }
52 |
53 | public function onBlockToHtmlBefore(Varien_Event_Observer $observer)
54 | {
55 | if (!$this->isDebugEnabled()) {
56 | return;
57 | }
58 |
59 | /** @var $block Mage_Core_Block_Abstract */
60 | $block = $observer->getEvent()->getBlock();
61 | // Saving block rendering time, used in onBlockToHtmlAfter()
62 | $this->_debug['start'][$block->getNameInLayout()] = microtime(true);
63 | $this->_debug['current_block'] = $block->setDebugId(uniqid(mt_rand()));
64 | }
65 |
66 | public function onBlockToHtmlAfter(Varien_Event_Observer $observer)
67 | {
68 | if (!$this->isDebugEnabled()) {
69 | return;
70 | }
71 |
72 | $block = $observer->getEvent()->getBlock();
73 | $this->_debug['current_block'] = null;
74 |
75 | // Block rendering duration
76 | $start = $this->_debug['start'][$block->getNameInLayout()];
77 | if ($start) {
78 | $blocks =& $this->_debug['blocks'];
79 | $parents = array();
80 | $parentBlock = $block->getParentBlock();
81 | while ($parentBlock) {
82 | $parents[] = $parentBlock->getNameInLayout();
83 | $parentBlock = $parentBlock->getParentBlock();
84 | }
85 | foreach (array_reverse($parents) as $parent) {
86 | $blocks =& $blocks[$parent]['children'];
87 | }
88 | $tpl = false;
89 | if ($block->getTemplateFile() && pathinfo($block->getTemplateFile(), PATHINFO_EXTENSION) == 'phtml') {
90 | $tpl = 'app' . DS . 'design' . DS . $block->getTemplateFile();
91 | }
92 | $blockInfo = array(
93 | 'debug_id' => $block->getDebugId(),
94 | 'name' => $block->getNameInLayout(),
95 | 'class' => get_class($block),
96 | 'tpl' => $tpl,
97 | 'took' => microtime(true) - $start,
98 | 'cached' => !is_null($block->getCacheLifetime()),
99 | );
100 | if (isset($blocks[$blockInfo['name']])) {
101 | $blocks[$blockInfo['name']] = array_merge($blocks[$blockInfo['name']], $blockInfo);
102 | } else {
103 | $blocks[] = $blockInfo;
104 | }
105 | }
106 | }
107 |
108 | public function onSqlQueryBefore(Varien_Event_Observer $observer)
109 | {
110 | if (!$this->isDebugEnabled()) {
111 | return;
112 | }
113 |
114 | /** @var $adapter Zend_Db_Adapter_Pdo_Abstract */
115 | $adapter = $observer->getEvent()->getAdapter();
116 | $sql = $observer->getEvent()->getQuery();
117 | $bind = $observer->getEvent()->getBind();
118 | $took = $observer->getEvent()->getTook();
119 | if ($adapter && $sql) {
120 | $debug = array(
121 | 'query' => $sql,
122 | 'took' => $took,
123 | 'stack' => array(),
124 | );
125 | $debug['query'] = $sql;
126 | if (is_string(key($bind))) {
127 | foreach ($bind as $field => $value) {
128 | $debug['query'] = str_replace($field, $adapter->quote($value), $debug['query']);
129 | }
130 | } else if (is_numeric(key($bind))) {
131 | $offset = 0;
132 | foreach ($bind as $value) {
133 | $pos = strpos($debug['query'], '?', $offset);
134 | if (null === $value) {
135 | $value = 'NULL';
136 | } else if (is_string($value)) {
137 | $value = $adapter->quote($value);
138 | }
139 | $debug['query'] = substr_replace($debug['query'], $value, $pos, 1);
140 | $offset = $pos + strlen($value);
141 | }
142 | }
143 | $debug['query'] .= ';';
144 | $backtrace = array_slice(debug_backtrace(false), 4);
145 | foreach ($backtrace as $data) {
146 | $file = false;
147 | if (isset($data['file'])) {
148 | $file = ltrim(str_replace(dirname($_SERVER['SCRIPT_FILENAME']), '', $data['file']), DS);
149 | }
150 | $function = $data['function'] . '()';
151 | if (isset($data['class'])) {
152 | $function = $data['class'] . $data['type'] . $function;
153 | }
154 | $debug['stack'][] = array(
155 | 'function' => $function,
156 | 'file' => $file,
157 | 'line' => isset($data['line']) ? $data['line'] : false,
158 | );
159 | }
160 | if (isset($this->_debug['current_block'])) {
161 | $this->_debug['sql']['blocks'][$this->_debug['current_block']->getDebugId()][] = $debug;
162 | }
163 | $this->_debug['sql']['queries'][] = $debug;
164 | }
165 | }
166 |
167 | public function getDebug()
168 | {
169 | return $this->_debug;
170 | }
171 |
172 | public function isDebugEnabled()
173 | {
174 | return $this->_debugEnabled;
175 | }
176 |
177 | protected function _getBlockInfoHtml(&$html, $block, $level = 0)
178 | {
179 | $indent = $level * 4;
180 | if (!empty($block['name'])) {
181 | $blockId = $block['debug_id'];
182 | $html .= '
';
183 | $html .= sprintf(
184 | '%s %s
%s (%s)',
185 | str_repeat(' ', $indent),
186 | str_pad(round($block['took'], 4), 6, STR_PAD_LEFT),
187 | $block['name'],
188 | $block['cached'] ? 'limegreen' : 'red',
189 | $block['cached'] ? 'cached' : 'not cached'
190 | );
191 | $html .= '
';
192 | $html .= str_repeat(' ', $indent + 7) . ' ' . str_pad($block['class'], 6, STR_PAD_LEFT);
193 | if (!empty($block['tpl'])) {
194 | $html .= '
';
195 | $html .= str_repeat(' ', $indent + 7) . ' ' . str_pad($block['tpl'], 6, STR_PAD_LEFT);
196 | }
197 | if (isset($this->_debug['sql']['blocks'][$blockId])) {
198 | $id = uniqid(mt_rand());
199 | $html .= '
';
200 | $onclick = "var el = document.getElementById('$id');
201 | el.style.display = el.style.display == 'none' ? 'block' : 'none';return false;";
202 | $html .= sprintf(
203 | '%s
SQL Queries (%d)',
204 | str_repeat(' ', $indent + 7),
205 | $onclick,
206 | count($this->_debug['sql']['blocks'][$blockId])
207 | );
208 | $html .= '
';
209 | $html .= '
';
210 | foreach ($this->_debug['sql']['blocks'][$blockId] as $i => $data) {
211 | $color = ($i % 2) ? '#f4f4f4' : '#dddddd';
212 | $html .= '- ';
213 | $html .= str_repeat(' ', $indent + 8) . $data['query'];
214 | $html .= '
';
215 | }
216 | $html .= '
';
217 | }
218 | $html .= '
';
219 | }
220 | if (isset($block['children'])) {
221 | foreach ($block['children'] as $child) {
222 | $this->_getBlockInfoHtml($html, $child, $level + 1);
223 | }
224 | }
225 |
226 | return $this;
227 | }
228 |
229 | protected function _getSqlDebugHtml()
230 | {
231 | $html = '';
232 | if (isset($this->_debug['sql']['queries'])) {
233 | $count = count($this->_debug['sql']['queries']);
234 | $html = '';
235 | $html .= 'All SQL Queries ('. $count .')';
236 | $html .= '
';
237 | $html .= '';
238 | foreach ($this->_debug['sql']['queries'] as $sql) {
239 | $html .= '- ';
240 | $html .= $sql['query'] . '
';
241 | $id = uniqid(mt_rand());
242 | $onclick = "var el = document.getElementById('$id');
243 | el.style.display = el.style.display == 'none' ? 'block' : 'none';return false;";
244 | $html .= 'Stack Trace ' . round($sql['took'], 4);
245 | $html .= '';
246 | foreach ($sql['stack'] as $i => $info) {
247 | $color = ($i % 2) ? '#f4f4f4' : '#dddddd';
248 | foreach ($info as $key => $value) {
249 | $key = str_pad($key, 8, ' ', STR_PAD_RIGHT);
250 | $html .= '- ' . $key . ' => ' . $value . '
';
251 | }
252 | }
253 | $html .= '
';
254 | $html .= ' ';
255 | }
256 | $html .= '
';
257 | }
258 |
259 | return $html;
260 | }
261 |
262 | protected function _getDebugHtml()
263 | {
264 | $html = '';
265 | $html .= '';
266 | $html .= 'Design';
267 | $html .= '
';
268 | $html .= sprintf('Package: %s
', Mage::getDesign()->getPackageName());
269 | $html .= sprintf('Theme: %s
', Mage::getDesign()->getTheme('default'));
270 | $html .= '';
271 | $html .= 'Layout Updates';
272 | $html .= '
';
273 | $html .= implode('
', Mage::app()->getLayout()->getUpdate()->getHandles());
274 | $html .= '';
275 | $html .= 'Rendered Blocks';
276 | $html .= '
';
277 | foreach ($this->_debug['blocks'] as $block) {
278 | $this->_getBlockInfoHtml($html, $block);
279 | }
280 | $html .= $this->_getSqlDebugHtml();
281 | $html .= '
';
282 |
283 | return $html;
284 | }
285 | }
--------------------------------------------------------------------------------
/app/code/community/Bubble/Debug/etc/config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | 1.1.0
6 |
7 |
8 |
9 |
10 |
11 | Bubble_Debug_Model
12 |
13 |
14 |
15 |
16 |
17 |
18 | singleton
19 | bubble_debug/observer
20 | onSendResponseBefore
21 |
22 |
23 |
24 |
25 |
26 |
27 | singleton
28 | bubble_debug/observer
29 | onBlockToHtmlBefore
30 |
31 |
32 |
33 |
34 |
35 |
36 | singleton
37 | bubble_debug/observer
38 | onBlockToHtmlAfter
39 |
40 |
41 |
42 |
43 |
44 |
45 | singleton
46 | bubble_debug/observer
47 | onSqlQueryBefore
48 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/app/code/local/Zend/Db/Adapter/Pdo/Abstract.php:
--------------------------------------------------------------------------------
1 | _config settings.
57 | *
58 | * @return string
59 | */
60 | protected function _dsn()
61 | {
62 | // baseline of DSN parts
63 | $dsn = $this->_config;
64 |
65 | // don't pass the username, password, charset, persistent and driver_options in the DSN
66 | unset($dsn['username']);
67 | unset($dsn['password']);
68 | unset($dsn['options']);
69 | unset($dsn['charset']);
70 | unset($dsn['persistent']);
71 | unset($dsn['driver_options']);
72 |
73 | // use all remaining parts in the DSN
74 | foreach ($dsn as $key => $val) {
75 | $dsn[$key] = "$key=$val";
76 | }
77 |
78 | return $this->_pdoType . ':' . implode(';', $dsn);
79 | }
80 |
81 | /**
82 | * Creates a PDO object and connects to the database.
83 | *
84 | * @return void
85 | * @throws Zend_Db_Adapter_Exception
86 | */
87 | protected function _connect()
88 | {
89 | // if we already have a PDO object, no need to re-connect.
90 | if ($this->_connection) {
91 | return;
92 | }
93 |
94 | // get the dsn first, because some adapters alter the $_pdoType
95 | $dsn = $this->_dsn();
96 |
97 | // check for PDO extension
98 | if (!extension_loaded('pdo')) {
99 | /**
100 | * @see Zend_Db_Adapter_Exception
101 | */
102 | #require_once 'Zend/Db/Adapter/Exception.php';
103 | throw new Zend_Db_Adapter_Exception('The PDO extension is required for this adapter but the extension is not loaded');
104 | }
105 |
106 | // check the PDO driver is available
107 | if (!in_array($this->_pdoType, PDO::getAvailableDrivers())) {
108 | /**
109 | * @see Zend_Db_Adapter_Exception
110 | */
111 | #require_once 'Zend/Db/Adapter/Exception.php';
112 | throw new Zend_Db_Adapter_Exception('The ' . $this->_pdoType . ' driver is not currently installed');
113 | }
114 |
115 | // create PDO connection
116 | $q = $this->_profiler->queryStart('connect', Zend_Db_Profiler::CONNECT);
117 |
118 | // add the persistence flag if we find it in our config array
119 | if (isset($this->_config['persistent']) && ($this->_config['persistent'] == true)) {
120 | $this->_config['driver_options'][PDO::ATTR_PERSISTENT] = true;
121 | }
122 |
123 | try {
124 | $this->_connection = new PDO(
125 | $dsn,
126 | $this->_config['username'],
127 | $this->_config['password'],
128 | $this->_config['driver_options']
129 | );
130 |
131 | $this->_profiler->queryEnd($q);
132 |
133 | // set the PDO connection to perform case-folding on array keys, or not
134 | $this->_connection->setAttribute(PDO::ATTR_CASE, $this->_caseFolding);
135 |
136 | // always use exceptions.
137 | $this->_connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
138 |
139 | } catch (PDOException $e) {
140 | /**
141 | * @see Zend_Db_Adapter_Exception
142 | */
143 | #require_once 'Zend/Db/Adapter/Exception.php';
144 | throw new Zend_Db_Adapter_Exception($e->getMessage(), $e->getCode(), $e);
145 | }
146 |
147 | }
148 |
149 | /**
150 | * Test if a connection is active
151 | *
152 | * @return boolean
153 | */
154 | public function isConnected()
155 | {
156 | return ((bool) ($this->_connection instanceof PDO));
157 | }
158 |
159 | /**
160 | * Force the connection to close.
161 | *
162 | * @return void
163 | */
164 | public function closeConnection()
165 | {
166 | $this->_connection = null;
167 | }
168 |
169 | /**
170 | * Prepares an SQL statement.
171 | *
172 | * @param string $sql The SQL statement with placeholders.
173 | * @param array $bind An array of data to bind to the placeholders.
174 | * @return PDOStatement
175 | */
176 | public function prepare($sql)
177 | {
178 | $this->_connect();
179 | $stmtClass = $this->_defaultStmtClass;
180 | if (!class_exists($stmtClass)) {
181 | #require_once 'Zend/Loader.php';
182 | Zend_Loader::loadClass($stmtClass);
183 | }
184 | $stmt = new $stmtClass($this, $sql);
185 | $stmt->setFetchMode($this->_fetchMode);
186 | return $stmt;
187 | }
188 |
189 | /**
190 | * Gets the last ID generated automatically by an IDENTITY/AUTOINCREMENT column.
191 | *
192 | * As a convention, on RDBMS brands that support sequences
193 | * (e.g. Oracle, PostgreSQL, DB2), this method forms the name of a sequence
194 | * from the arguments and returns the last id generated by that sequence.
195 | * On RDBMS brands that support IDENTITY/AUTOINCREMENT columns, this method
196 | * returns the last value generated for such a column, and the table name
197 | * argument is disregarded.
198 | *
199 | * On RDBMS brands that don't support sequences, $tableName and $primaryKey
200 | * are ignored.
201 | *
202 | * @param string $tableName OPTIONAL Name of table.
203 | * @param string $primaryKey OPTIONAL Name of primary key column.
204 | * @return string
205 | */
206 | public function lastInsertId($tableName = null, $primaryKey = null)
207 | {
208 | $this->_connect();
209 | return $this->_connection->lastInsertId();
210 | }
211 |
212 | /**
213 | * Special handling for PDO query().
214 | * All bind parameter names must begin with ':'
215 | *
216 | * @param string|Zend_Db_Select $sql The SQL statement with placeholders.
217 | * @param array $bind An array of data to bind to the placeholders.
218 | * @return Zend_Db_Statement_Pdo
219 | * @throws Zend_Db_Adapter_Exception To re-throw PDOException.
220 | */
221 | public function query($sql, $bind = array())
222 | {
223 | if (empty($bind) && $sql instanceof Zend_Db_Select) {
224 | $bind = $sql->getBind();
225 | }
226 |
227 | if (is_array($bind)) {
228 | foreach ($bind as $name => $value) {
229 | if (!is_int($name) && !preg_match('/^:/', $name)) {
230 | $newName = ":$name";
231 | unset($bind[$name]);
232 | $bind[$newName] = $value;
233 | }
234 | }
235 | }
236 |
237 | // Bubble_Debug start
238 | try {
239 | $start = microtime(true);
240 | $result = parent::query($sql, $bind);
241 |
242 | if (class_exists('Mage')) {
243 | Mage::dispatchEvent('bubble_debug_sql_query_before', array(
244 | 'adapter' => $this,
245 | 'query' => $sql,
246 | 'bind' => $bind,
247 | 'took' => microtime(true) - $start,
248 | ));
249 | }
250 |
251 | return $result;
252 | } catch (PDOException $e) {
253 | /**
254 | * @see Zend_Db_Statement_Exception
255 | */
256 | #require_once 'Zend/Db/Statement/Exception.php';
257 | throw new Zend_Db_Statement_Exception($e->getMessage(), $e->getCode(), $e);
258 | }
259 | // Bubble_Debug end
260 | }
261 |
262 | /**
263 | * Executes an SQL statement and return the number of affected rows
264 | *
265 | * @param mixed $sql The SQL statement with placeholders.
266 | * May be a string or Zend_Db_Select.
267 | * @return integer Number of rows that were modified
268 | * or deleted by the SQL statement
269 | */
270 | public function exec($sql)
271 | {
272 | if ($sql instanceof Zend_Db_Select) {
273 | $sql = $sql->assemble();
274 | }
275 |
276 | try {
277 | $affected = $this->getConnection()->exec($sql);
278 |
279 | if ($affected === false) {
280 | $errorInfo = $this->getConnection()->errorInfo();
281 | /**
282 | * @see Zend_Db_Adapter_Exception
283 | */
284 | #require_once 'Zend/Db/Adapter/Exception.php';
285 | throw new Zend_Db_Adapter_Exception($errorInfo[2]);
286 | }
287 |
288 | return $affected;
289 | } catch (PDOException $e) {
290 | /**
291 | * @see Zend_Db_Adapter_Exception
292 | */
293 | #require_once 'Zend/Db/Adapter/Exception.php';
294 | throw new Zend_Db_Adapter_Exception($e->getMessage(), $e->getCode(), $e);
295 | }
296 | }
297 |
298 | /**
299 | * Quote a raw string.
300 | *
301 | * @param string $value Raw string
302 | * @return string Quoted string
303 | */
304 | protected function _quote($value)
305 | {
306 | if (is_int($value) || is_float($value)) {
307 | return $value;
308 | }
309 | $this->_connect();
310 | return $this->_connection->quote($value);
311 | }
312 |
313 | /**
314 | * Begin a transaction.
315 | */
316 | protected function _beginTransaction()
317 | {
318 | $this->_connect();
319 | $this->_connection->beginTransaction();
320 | }
321 |
322 | /**
323 | * Commit a transaction.
324 | */
325 | protected function _commit()
326 | {
327 | $this->_connect();
328 | $this->_connection->commit();
329 | }
330 |
331 | /**
332 | * Roll-back a transaction.
333 | */
334 | protected function _rollBack() {
335 | $this->_connect();
336 | $this->_connection->rollBack();
337 | }
338 |
339 | /**
340 | * Set the PDO fetch mode.
341 | *
342 | * @todo Support FETCH_CLASS and FETCH_INTO.
343 | *
344 | * @param int $mode A PDO fetch mode.
345 | * @return void
346 | * @throws Zend_Db_Adapter_Exception
347 | */
348 | public function setFetchMode($mode)
349 | {
350 | //check for PDO extension
351 | if (!extension_loaded('pdo')) {
352 | /**
353 | * @see Zend_Db_Adapter_Exception
354 | */
355 | #require_once 'Zend/Db/Adapter/Exception.php';
356 | throw new Zend_Db_Adapter_Exception('The PDO extension is required for this adapter but the extension is not loaded');
357 | }
358 | switch ($mode) {
359 | case PDO::FETCH_LAZY:
360 | case PDO::FETCH_ASSOC:
361 | case PDO::FETCH_NUM:
362 | case PDO::FETCH_BOTH:
363 | case PDO::FETCH_NAMED:
364 | case PDO::FETCH_OBJ:
365 | $this->_fetchMode = $mode;
366 | break;
367 | default:
368 | /**
369 | * @see Zend_Db_Adapter_Exception
370 | */
371 | #require_once 'Zend/Db/Adapter/Exception.php';
372 | throw new Zend_Db_Adapter_Exception("Invalid fetch mode '$mode' specified");
373 | break;
374 | }
375 | }
376 |
377 | /**
378 | * Check if the adapter supports real SQL parameters.
379 | *
380 | * @param string $type 'positional' or 'named'
381 | * @return bool
382 | */
383 | public function supportsParameters($type)
384 | {
385 | switch ($type) {
386 | case 'positional':
387 | case 'named':
388 | default:
389 | return true;
390 | }
391 | }
392 |
393 | /**
394 | * Retrieve server version in PHP style
395 | *
396 | * @return string
397 | */
398 | public function getServerVersion()
399 | {
400 | $this->_connect();
401 | try {
402 | $version = $this->_connection->getAttribute(PDO::ATTR_SERVER_VERSION);
403 | } catch (PDOException $e) {
404 | // In case of the driver doesn't support getting attributes
405 | return null;
406 | }
407 | $matches = null;
408 | if (preg_match('/((?:[0-9]{1,2}\.){1,3}[0-9]{1,2})/', $version, $matches)) {
409 | return $matches[1];
410 | } else {
411 | return null;
412 | }
413 | }
414 | }
415 |
416 |
--------------------------------------------------------------------------------
/app/etc/modules/Bubble_Debug.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | true
6 | community
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------