diff --git a/lib/MC/Google/Visualization.php b/lib/MC/Google/Visualization.php index e44ca02..89c1fb8 100644 --- a/lib/MC/Google/Visualization.php +++ b/lib/MC/Google/Visualization.php @@ -91,8 +91,6 @@ public function __construct(PDO $db = null, $dialect = 'mysql') * Set the database connection to use when handling the entire request or getting pivot values. * * @param null|PDO $db the database connection to use - or null if you want to handle your own queries - * - * @throws Visualization_Error */ public function setDB(PDO $db = null) { @@ -234,187 +232,6 @@ public function handleQuery($query, array $params) return $response; } - /** - * Return the response appropriate to tell the visualization client that an error has occurred. - * - * @param int $reqid the request ID that caused the error - * @param string $detail_msg the detailed message to send along with the error - * @param string $handler - * @param string $code the code for the error (like "error", "server_error", "invalid_query", "access_denied", etc.) - * @param string $summary_msg a short description of the error, appropriate to show to end users - * - * @return string the string to output that will cause the visualization client to detect an error - */ - public function handleError($reqid, $detail_msg, $handler = 'google.visualization.Query.setResponse', $code = 'error', $summary_msg = null) - { - if (null === $summary_msg) { - $summary_msg = $detail_msg; - } - $handler = $handler ?: 'google.visualization.Query.setResponse'; - - return $handler.'({version:"'.$this->version.'",reqId:"'.$reqid.'",status:"error",errors:[{reason:'.json_encode($code).',message:'.json_encode($summary_msg).',detailed_message:'.json_encode($detail_msg).'}]});'; - } - - /** - * Given the metadata for a query and the entities it's working against, generate the SQL. - * - * @param array $meta the results of generateMetadata() on the parsed visualization query - * - * @throws Visualization_QueryError - * - * @return string the SQL version of the visualization query - */ - public function generateSQL(array &$meta) - { - if (!isset($meta['query_fields'])) { - $meta['query_fields'] = $meta['select']; - } - - if (isset($meta['pivot'])) { - //Pivot queries are special - they require an entity to be passed and modify the query directly - $entity = $meta['entity']; - $pivot_fields = []; - $pivot_joins = []; - $pivot_group = []; - foreach ($meta['pivot'] as $entity_field) { - $field = $entity['fields'][$entity_field]; - if (isset($field['callback'])) { - throw new Visualization_QueryError('Callback fields cannot be used as pivots: "'.$entity_field.'"'); - } - $pivot_fields[] = $field['field'].' AS '.$entity_field; - $pivot_group[] = $entity_field; - if (isset($field['join']) && !in_array($entity['joins'][$field['join']], $pivot_joins, true)) { - $pivot_joins[] = $entity['joins'][$field['join']]; - } - } - - $pivot_sql = 'SELECT '.implode(', ', $pivot_fields).' FROM '.$meta['table']; - if (!empty($pivot_joins)) { - $pivot_sql .= ' '.implode(' ', $pivot_joins); - } - $pivot_sql .= ' GROUP BY '.implode(', ', $pivot_group); - - $func_fields = []; - $new_fields = []; - foreach ($meta['query_fields'] as $field) { - if (is_array($field)) { - $func_fields[] = $field; - } else { - $new_fields[] = $field; - } - } - $meta['query_fields'] = $new_fields; - - $stmt = $this->db->query($pivot_sql); - foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { - //Create a version of all function-ed fields for each unique combination of pivot values - foreach ($func_fields as $field) { - $field[2] = $row; - - $meta['query_fields'][] = $field; - } - } - - //For pivot queries, the fields we return and the fields we query against are always the same - $meta['select'] = $meta['query_fields']; - } - - $query_sql = []; - $join_sql = $meta['joins']; - foreach ($meta['query_fields'] as $field) { - $func = null; - $pivot_cond = null; - if (is_array($field)) { - $func = $field[0]; - $pivot_cond = isset($field[2]) ? $field[2] : null; - $field = $field[1]; - } - $query_sql[] = $this->getFieldSQL($field, $meta['field_spec'][$field], true, $func, $pivot_cond, $meta['field_spec']); - } - - $where_str = null; - if (isset($meta['where'])) { - $where_str = []; - foreach ($meta['where'] as &$where_part) { - //Replace field references with their SQL forms - switch ($where_part['type']) { - case 'where_field': - $where_part['value'] = $this->getFieldSQL($where_part['value'], $meta['field_spec'][$where_part['value']]); - - break; - case 'datetime': - case 'timestamp': - $where_part['value'] = $this->convertDateTime(trim($where_part['value'][1], '\'"')); - - break; - case 'timeofday': - $where_part['value'] = $this->convertTime(trim($where_part['value'][1], '\'"')); - - break; - case 'date': - $where_part['value'] = $this->convertDate(trim($where_part['value'][1], '\'"')); - - break; - case 'null': - case 'notnull': - $where_part['value'] = strtoupper(implode(' ', $where_part['value'])); - - break; - } - - $where_str[] = $where_part['value']; - } - $where_str = implode(' ', $where_str); - } - - $sql = 'SELECT '.implode(', ', $query_sql).' FROM '.$meta['table']; - if (!empty($join_sql)) { - $sql .= ' '.implode(' ', $join_sql); - } - - if ($where_str || isset($meta['global_where'])) { - if (!$where_str) { - $where_str = '1=1'; - } - $sql .= ' WHERE ('.$where_str.')'; - if (isset($meta['global_where'])) { - $sql .= ' AND '.$meta['global_where']; - } - } - - if (isset($meta['groupby'])) { - $group_sql = []; - foreach ($meta['groupby'] as $group) { - $group_sql[] = $this->getFieldSQL($group, $meta['field_spec'][$group]); - } - $sql .= ' GROUP BY '.implode(', ', $group_sql); - } - - if (isset($meta['orderby'])) { - $sql .= ' ORDER BY'; - $first = true; - foreach ($meta['orderby'] as $field => $dir) { - if (isset($meta['field_spec'][$field]['sort_field'])) { - //An entity field can delegate sorting to another field by using the "sort_field" key - $field = $meta['field_spec'][$field]['sort_field']; - } - $spec = $meta['field_spec'][$field]; - if (!$first) { - $sql .= ','; - } - - $sql .= ' '.$this->getFieldSQL($field, $spec).' '.strtoupper($dir); - $first = false; - } - } - - if (isset($meta['limit']) || isset($meta['offset'])) { - $sql .= $this->convertLimit($meta['limit'], $meta['offset']); - } - - return $sql; - } - /** * Given the results of parseQuery(), introspect against the entity definitions provided and return the metadata array used to generate the SQL. * @@ -585,56 +402,344 @@ public function generateMetadata(array $query) } /** - * Parse the query according to the visualization query grammar, and break down the constituent parts. - * - * @param string $str the query string to parse + * Add a new entity (table) to the visualization server that maps onto one or more SQL database tables. * - * @throws ParseError - * @throws Visualization_QueryError - * @throws Parser\DefError + * @param string $name the name of the entity - should be used in the "from" clause of visualization queries + * @param array $spec optional spec array with keys "fields", "joins", "table", and "where" to define the mapping between visualization queries and SQL queries * - * @return array the parsed query as an array, keyed by each part of the query (select, from, where, groupby, pivot, orderby, limit, offset, label, format, options + * @throws Visualization_Error */ - public function parseQuery($str) + public function addEntity($name, array $spec = []) { - $query = []; - $tokens = $this->getGrammar()->parse($str); + $entity = ['table' => isset($spec['table']) ? $spec['table'] : $name, 'fields' => [], 'joins' => []]; + $this->entities[$name] = $entity; - foreach ($tokens->getChildren() as $token) { - switch ($token->name) { - case 'select': - $sfields = $token->getChildren(); - $sfields = $sfields[1]; + if (isset($spec['fields'])) { + foreach ($spec['fields'] as $field_name => $field_spec) { + $this->addEntityField($name, $field_name, $field_spec); + } + } - $this->parseFieldTokens($sfields, $fields); - $query['select'] = $fields; + if (isset($spec['joins'])) { + foreach ($spec['joins'] as $join_name => $join_sql) { + $this->addEntityJoin($name, $join_name, $join_sql); + } + } - break; - case 'from': - $vals = $token->getValues(); - $query['from'] = $vals[1]; + if (isset($spec['where'])) { + $this->setEntityWhere($name, $spec['where']); + } + } - break; - case 'where': - $where_tokens = $token->getChildren(); - $where_tokens = $where_tokens[1]; - $this->parseWhereTokens($where_tokens, $where); - $query['where'] = $where; + /** + * Set the default entity to be used when a "from" clause is omitted from a query. Set to null to require a "from" clause for all queries. + * + * @param null|string $default the new default entity + * + * @throws Visualization_Error + */ + public function setDefaultEntity($default = null) + { + if (null !== $default && !isset($this->entities[$default])) { + throw new Visualization_Error('No entity exists with name "'.$default.'"'); + } - break; - case 'groupby': - $groupby = $token->getValues(); - array_shift($groupby); - array_shift($groupby); - $query['groupby'] = $groupby; + $this->default_entity = $default; + } - break; - case 'pivot': - if (null === $this->db) { - throw new Visualization_QueryError('Pivots require a PDO database connection'); - } - $pivot = $token->getValues(); - array_shift($pivot); + /** + * Given an associative array of key => value pairs and the results of generateMetadata, return the visualization results fragment for the particular row. + * + * @param array $row the row values as an array + * @param array $meta the metadata for the query (use generateMetadata()) + * + * @throws Visualization_Error + * + * @return string the string fragment to include in the results back to the javascript client + */ + public function getRowValues(array $row, array $meta) + { + $vals = []; + foreach ($meta['select'] as $field) { + if (is_array($field)) { + $function = $field[0]; + if (isset($field[2])) { + $key = implode(',', $field[2]).' '.$function.'-'.$field[1]; + } else { + $key = $function.'-'.$field[1]; + } + $field = $field[1]; + } else { + $function = null; + $key = $field; + } + + $callback_response = null; + + $field_meta = $meta['field_spec'][$field]; + if (isset($field_meta['callback'])) { + if (isset($field_meta['extra'])) { + $params = [$row, $field_meta['fields']]; + $params = array_merge($params, $field_meta['extra']); + $callback_response = call_user_func_array($field_meta['callback'], $params); + } else { + $callback_response = call_user_func($field_meta['callback'], $row, $field_meta['fields']); + } + if (is_array($callback_response)) { + $val = $callback_response['value']; + } else { + $val = $callback_response; + } + } else { + $val = $row[$key]; + } + + $type = isset($function) ? 'number' : $field_meta['type']; + + $format = ''; + if (isset($meta['formats'][$field])) { + $format = $meta['formats'][$field]; + } elseif (isset($this->default_format[$type])) { + $format = $this->default_format[$type]; + } + + switch ($type) { + case '': + case null: + case 'text': + $val = json_encode((string) $val); + $formatted = null; + + break; + case 'number': + $val = (float) $val; + if (preg_match('/^num:(\d+)(.*)$/i', $format, $matches)) { + $digits = (int) $matches[1]; + $extras = $matches[2]; + if (is_array($extras) && (2 === count($extras))) { + $formatted = number_format($val, $digits, $extras[0], $extras[1]); + } else { + $formatted = number_format($val, $digits); + } + } elseif ('dollars' === $format) { + $formatted = '$'.number_format($val, 2); + } elseif ('percent' === $format) { + $formatted = number_format($val * 100, 1).'%'; + } else { + $formatted = sprintf($format, $val); + } + $val = json_encode($val); + + break; + case 'boolean': + $val = (bool) $val; + list($format_false, $format_true) = explode(':', $format, 2); + $formatted = $val ? $format_true : $format_false; + $val = json_encode((bool) $val); + + break; + case 'date': + if (!is_numeric($val) || 6 !== strlen($val)) { + $time = strtotime($val); + list($year, $month, $day) = explode('-', date('Y-m-d', $time)); + $formatted = date($format, $time); + } else { + $year = substr($val, 0, 4); + $week = substr($val, -2); + $time = strtotime($year.'0104 +'.$week.' weeks'); + $monday = strtotime('-'.((int) date('w', $time) - 1).' days', $time); + list($year, $month, $day) = explode('-', date('Y-m-d', $monday)); + $formatted = date($format, $monday); + } + $val = 'new Date('.(int) $year.','.((int) $month - 1).','.(int) $day.')'; + + break; + case 'datetime': + case 'timestamp': + $time = strtotime($val); + list($year, $month, $day, $hour, $minute, $second) = explode('-', date('Y-m-d-H-i-s', $time)); + // MALC - Force us to consider the date as UTC... + $val = 'new Date(Date.UTC('.(int) $year.','.((int) $month - 1).','.(int) $day.','.(int) $hour.','.(int) $minute.','.(int) $second.'))'; + $formatted = date($format, $time); + + break; + case 'time': + $time = strtotime($val); + list($hour, $minute, $second) = explode('-', date('H-i-s', $time)); + $val = '['.(int) $hour.','.(int) $minute.','.(int) $second.',0]'; + $formatted = date($format, $time); + + break; + case 'binary': + $formatted = '0x'.current(unpack('H*', $val)); + $val = '0x'.current(unpack('H*', $val)); + $val = json_encode($val); + + break; + default: + throw new Visualization_Error('Unknown field type "'.$type.'"'); + } + + if (isset($callback_response['formatted'])) { + $formatted = $callback_response['formatted']; + } + + if (!isset($meta['options']['no_values'])) { + $cell = '{v:'.$val; + if (!isset($meta['options']['no_format'])) { + if (null !== $formatted) { + $cell .= ',f:'.json_encode($formatted); + } + } + } else { + $cell = '{f:'.json_encode($formatted); + } + + $vals[] = $cell.'}'; + } + + return '{c:['.implode(',', $vals).']}'; + } + + /** + * A utility method for testing - take a visualization query, and return the SQL that would be generated. + * + * @param string $query the visualization query to run + * + * @throws Visualization_QueryError + * @throws Visualization_Error + * @throws ParseError + * @throws DefError + * + * @return string the SQL that should be sent to the database + */ + public function getSQL($query) + { + $tokens = $this->parseQuery($query); + $meta = $this->generateMetadata($tokens); + + return $this->generateSQL($meta); + } + + /** + * Use MC_Parser to generate a grammar that matches the query language specified here: http://code.google.com/apis/visualization/documentation/querylanguage.html. + * + * @throws DefError + * + * @return Def the grammar for the query language + */ + public function getGrammar() + { + $p = new Parser(); + $ident = $p->oneOf( + $p->word($p->alphas().'_', $p->alphanums().'_'), + $p->quotedString('`') + ); + + $literal = $p->oneOf( + $p->number()->name('number'), + $p->hexNumber()->name('number'), + $p->quotedString()->name('string'), + $p->boolean('lower')->name('boolean'), + $p->set($p->keyword('date', true), $p->quotedString())->name('date'), + $p->set($p->keyword('timeofday', true), $p->quotedString())->name('time'), + $p->set( + $p->oneOf( + $p->keyword('datetime', true), + $p->keyword('timestamp', true) + ), + $p->quotedString() + )->name('datetime') + ); + + $function = $p->set($p->oneOf($p->literal('min', true), $p->literal('max', true), $p->literal('count', true), $p->literal('avg', true), $p->literal('sum', true))->name('func_name'), $p->literal('(')->suppress(), $ident, $p->literal(')')->suppress())->name('function'); + + $select = $p->set($p->keyword('select', true), $p->oneOf($p->keyword('*'), $p->delimitedList($p->oneOf($function, $ident))))->name('select'); + $from = $p->set($p->keyword('from', true), $ident)->name('from'); + + // Malc - Added 'Like' 20130219 + $comparison = $p->oneOf($p->literal('like'), $p->literal('<'), $p->literal('<='), $p->literal('>'), $p->literal('>='), $p->literal('='), $p->literal('!='), $p->literal('<>'))->name('operator'); + + $expr = $p->recursive(); + $value = $p->oneOf($literal, $ident->name('where_field')); + $cond = $p->oneOf( + $p->set($value, $comparison, $value), + $p->set($value, $p->set($p->keyword('is', true), $p->literal('null', true))->name('isnull')), + $p->set($value, $p->set($p->keyword('is', true), $p->keyword('not', true), $p->literal('null', true))->name('notnull')), + $p->set($p->literal('(')->name('sep'), $expr, $p->literal(')')->name('sep')) + ); + + $andor = $p->oneOf($p->keyword('and', true), $p->keyword('or', true))->name('andor_sep'); + + $expr->replace($p->set($cond, $p->zeroOrMore($p->set($andor, $expr)))); + + $where = $p->set($p->keyword('where', true), $expr)->name('where'); + + $groupby = $p->set($p->keyword('group', true), $p->keyword('by', true), $p->delimitedList($ident))->name('groupby'); + $pivot = $p->set($p->keyword('pivot', true), $p->delimitedList($ident))->name('pivot'); + + $orderby_clause = $p->set($ident, $p->optional($p->oneOf($p->literal('asc', true), $p->literal('desc', true)))); + $orderby = $p->set($p->keyword('order', true), $p->keyword('by', true), $p->delimitedList($orderby_clause))->name('orderby'); + $limit = $p->set($p->keyword('limit', true), $p->word($p->nums()))->name('limit'); + $offset = $p->set($p->keyword('offset', true), $p->word($p->nums()))->name('offset'); + $label = $p->set($p->keyword('label', true), $p->delimitedList($p->set($ident, $p->quotedString())))->name('label'); + $format = $p->set($p->keyword('format', true), $p->delimitedList($p->set($ident, $p->quotedString())))->name('format'); + $options = $p->set($p->keyword('options', true), $p->delimitedList($p->word($p->alphas().'_')))->name('options'); + + return $p->set($p->optional($select), $p->optional($from), $p->optional($where), $p->optional($groupby), $p->optional($pivot), $p->optional($orderby), $p->optional($limit), $p->optional($offset), $p->optional($label), $p->optional($format), $p->optional($options)); + } + + /** + * Parse the query according to the visualization query grammar, and break down the constituent parts. + * + * @param string $str the query string to parse + * + * @throws ParseError + * @throws Visualization_QueryError + * @throws Parser\DefError + * + * @return array the parsed query as an array, keyed by each part of the query (select, from, where, groupby, pivot, orderby, limit, offset, label, format, options + */ + public function parseQuery($str) + { + $query = []; + $tokens = $this->getGrammar()->parse($str); + + foreach ($tokens->getChildren() as $token) { + switch ($token->name) { + case 'select': + $sfields = $token->getChildren(); + $sfields = $sfields[1]; + + $this->parseFieldTokens($sfields, $fields); + $query['select'] = $fields; + + break; + case 'from': + $vals = $token->getValues(); + $query['from'] = $vals[1]; + + break; + case 'where': + $where_tokens = $token->getChildren(); + $where_tokens = $where_tokens[1]; + $this->parseWhereTokens($where_tokens, $where); + $query['where'] = $where; + + break; + case 'groupby': + $groupby = $token->getValues(); + array_shift($groupby); + array_shift($groupby); + $query['groupby'] = $groupby; + + break; + case 'pivot': + if (null === $this->db) { + throw new Visualization_QueryError('Pivots require a PDO database connection'); + } + $pivot = $token->getValues(); + array_shift($pivot); $query['pivot'] = $pivot; break; @@ -716,33 +821,24 @@ public function parseQuery($str) } /** - * Add a new entity (table) to the visualization server that maps onto one or more SQL database tables. + * Return the response appropriate to tell the visualization client that an error has occurred. * - * @param string $name the name of the entity - should be used in the "from" clause of visualization queries - * @param array $spec optional spec array with keys "fields", "joins", "table", and "where" to define the mapping between visualization queries and SQL queries + * @param int $reqid the request ID that caused the error + * @param string $detail_msg the detailed message to send along with the error + * @param string $handler + * @param string $code the code for the error (like "error", "server_error", "invalid_query", "access_denied", etc.) + * @param string $summary_msg a short description of the error, appropriate to show to end users * - * @throws Visualization_Error + * @return string the string to output that will cause the visualization client to detect an error */ - public function addEntity($name, array $spec = []) + protected function handleError($reqid, $detail_msg, $handler = 'google.visualization.Query.setResponse', $code = 'error', $summary_msg = null) { - $entity = ['table' => isset($spec['table']) ? $spec['table'] : $name, 'fields' => [], 'joins' => []]; - $this->entities[$name] = $entity; - - if (isset($spec['fields'])) { - foreach ($spec['fields'] as $field_name => $field_spec) { - $this->addEntityField($name, $field_name, $field_spec); - } - } - - if (isset($spec['joins'])) { - foreach ($spec['joins'] as $join_name => $join_sql) { - $this->addEntityJoin($name, $join_name, $join_sql); - } + if (null === $summary_msg) { + $summary_msg = $detail_msg; } + $handler = $handler ?: 'google.visualization.Query.setResponse'; - if (isset($spec['where'])) { - $this->setEntityWhere($name, $spec['where']); - } + return $handler.'({version:"'.$this->version.'",reqId:"'.$reqid.'",status:"error",errors:[{reason:'.json_encode($code).',message:'.json_encode($summary_msg).',detailed_message:'.json_encode($detail_msg).'}]});'; } /** @@ -754,7 +850,7 @@ public function addEntity($name, array $spec = []) * * @throws Visualization_Error */ - public function addEntityField($entity, $field, array $spec) + protected function addEntityField($entity, $field, array $spec) { if (!isset($spec['field']) && !isset($spec['callback'])) { throw new Visualization_Error('Entity fields must either be mapped to database fields or given callback functions'); @@ -780,7 +876,7 @@ public function addEntityField($entity, $field, array $spec) * * @throws Visualization_Error */ - public function addEntityJoin($entity, $join, $sql) + protected function addEntityJoin($entity, $join, $sql) { if (!isset($this->entities[$entity])) { throw new Visualization_Error('No entity table defined with name "'.$entity.'"'); @@ -797,7 +893,7 @@ public function addEntityJoin($entity, $join, $sql) * * @throws Visualization_Error */ - public function setEntityWhere($entity, $where) + protected function setEntityWhere($entity, $where) { if (!isset($this->entities[$entity])) { throw new Visualization_Error('No entity table defined with name "'.$entity.'"'); @@ -807,19 +903,163 @@ public function setEntityWhere($entity, $where) } /** - * Set the default entity to be used when a "from" clause is omitted from a query. Set to null to require a "from" clause for all queries. + * Given the metadata for a query and the entities it's working against, generate the SQL. * - * @param null|string $default the new default entity + * @param array $meta the results of generateMetadata() on the parsed visualization query * - * @throws Visualization_Error + * @throws Visualization_QueryError + * + * @return string the SQL version of the visualization query */ - public function setDefaultEntity($default = null) + protected function generateSQL(array &$meta) { - if (null !== $default && !isset($this->entities[$default])) { - throw new Visualization_Error('No entity exists with name "'.$default.'"'); + if (!isset($meta['query_fields'])) { + $meta['query_fields'] = $meta['select']; + } + + if (isset($meta['pivot'])) { + //Pivot queries are special - they require an entity to be passed and modify the query directly + $entity = $meta['entity']; + $pivot_fields = []; + $pivot_joins = []; + $pivot_group = []; + foreach ($meta['pivot'] as $entity_field) { + $field = $entity['fields'][$entity_field]; + if (isset($field['callback'])) { + throw new Visualization_QueryError('Callback fields cannot be used as pivots: "'.$entity_field.'"'); + } + $pivot_fields[] = $field['field'].' AS '.$entity_field; + $pivot_group[] = $entity_field; + if (isset($field['join']) && !in_array($entity['joins'][$field['join']], $pivot_joins, true)) { + $pivot_joins[] = $entity['joins'][$field['join']]; + } + } + + $pivot_sql = 'SELECT '.implode(', ', $pivot_fields).' FROM '.$meta['table']; + if (!empty($pivot_joins)) { + $pivot_sql .= ' '.implode(' ', $pivot_joins); + } + $pivot_sql .= ' GROUP BY '.implode(', ', $pivot_group); + + $func_fields = []; + $new_fields = []; + foreach ($meta['query_fields'] as $field) { + if (is_array($field)) { + $func_fields[] = $field; + } else { + $new_fields[] = $field; + } + } + $meta['query_fields'] = $new_fields; + + $stmt = $this->db->query($pivot_sql); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + //Create a version of all function-ed fields for each unique combination of pivot values + foreach ($func_fields as $field) { + $field[2] = $row; + + $meta['query_fields'][] = $field; + } + } + + //For pivot queries, the fields we return and the fields we query against are always the same + $meta['select'] = $meta['query_fields']; + } + + $query_sql = []; + $join_sql = $meta['joins']; + foreach ($meta['query_fields'] as $field) { + $func = null; + $pivot_cond = null; + if (is_array($field)) { + $func = $field[0]; + $pivot_cond = isset($field[2]) ? $field[2] : null; + $field = $field[1]; + } + $query_sql[] = $this->getFieldSQL($field, $meta['field_spec'][$field], true, $func, $pivot_cond, $meta['field_spec']); + } + + $where_str = null; + if (isset($meta['where'])) { + $where_str = []; + foreach ($meta['where'] as &$where_part) { + //Replace field references with their SQL forms + switch ($where_part['type']) { + case 'where_field': + $where_part['value'] = $this->getFieldSQL($where_part['value'], $meta['field_spec'][$where_part['value']]); + + break; + case 'datetime': + case 'timestamp': + $where_part['value'] = $this->convertDateTime(trim($where_part['value'][1], '\'"')); + + break; + case 'timeofday': + $where_part['value'] = $this->convertTime(trim($where_part['value'][1], '\'"')); + + break; + case 'date': + $where_part['value'] = $this->convertDate(trim($where_part['value'][1], '\'"')); + + break; + case 'null': + case 'notnull': + $where_part['value'] = strtoupper(implode(' ', $where_part['value'])); + + break; + } + + $where_str[] = $where_part['value']; + } + $where_str = implode(' ', $where_str); + } + + $sql = 'SELECT '.implode(', ', $query_sql).' FROM '.$meta['table']; + if (!empty($join_sql)) { + $sql .= ' '.implode(' ', $join_sql); + } + + if ($where_str || isset($meta['global_where'])) { + if (!$where_str) { + $where_str = '1=1'; + } + $sql .= ' WHERE ('.$where_str.')'; + if (isset($meta['global_where'])) { + $sql .= ' AND '.$meta['global_where']; + } + } + + if (isset($meta['groupby'])) { + $group_sql = []; + foreach ($meta['groupby'] as $group) { + $group_sql[] = $this->getFieldSQL($group, $meta['field_spec'][$group]); + } + $sql .= ' GROUP BY '.implode(', ', $group_sql); + } + + if (isset($meta['orderby'])) { + $sql .= ' ORDER BY'; + $first = true; + foreach ($meta['orderby'] as $field => $dir) { + if (isset($meta['field_spec'][$field]['sort_field'])) { + //An entity field can delegate sorting to another field by using the "sort_field" key + $field = $meta['field_spec'][$field]['sort_field']; + } + $spec = $meta['field_spec'][$field]; + if (!$first) { + $sql .= ','; + } + + $sql .= ' '.$this->getFieldSQL($field, $spec).' '.strtoupper($dir); + $first = false; + } } - $this->default_entity = $default; + if (isset($meta['limit']) || isset($meta['offset'])) { + $sql .= $this->convertLimit($meta['limit'], $meta['offset']); + } + + return $sql; } /** @@ -831,7 +1071,7 @@ public function setDefaultEntity($default = null) * * @return string the initial output string for a successful query */ - public function getSuccessInit(array $meta) + protected function getSuccessInit(array $meta) { $handler = $meta['req_params']['responseHandler'] ?: 'google.visualization.Query.setResponse'; $version = $meta['req_params']['version'] ?: $this->version; @@ -848,7 +1088,7 @@ public function getSuccessInit(array $meta) * * @return string */ - public function getTableInit(array $meta) + protected function getTableInit(array $meta) { $field_init = []; foreach ($meta['select'] as $field) { @@ -908,253 +1148,11 @@ public function getTableInit(array $meta) return '{cols: ['.implode(',', $field_init).'],rows: ['; } - /** - * Given an associative array of key => value pairs and the results of generateMetadata, return the visualization results fragment for the particular row. - * - * @param array $row the row values as an array - * @param array $meta the metadata for the query (use generateMetadata()) - * - * @throws Visualization_Error - * - * @return string the string fragment to include in the results back to the javascript client - */ - public function getRowValues(array $row, array $meta) - { - $vals = []; - foreach ($meta['select'] as $field) { - if (is_array($field)) { - $function = $field[0]; - if (isset($field[2])) { - $key = implode(',', $field[2]).' '.$function.'-'.$field[1]; - } else { - $key = $function.'-'.$field[1]; - } - $field = $field[1]; - } else { - $function = null; - $key = $field; - } - - $callback_response = null; - - $field_meta = $meta['field_spec'][$field]; - if (isset($field_meta['callback'])) { - if (isset($field_meta['extra'])) { - $params = [$row, $field_meta['fields']]; - $params = array_merge($params, $field_meta['extra']); - $callback_response = call_user_func_array($field_meta['callback'], $params); - } else { - $callback_response = call_user_func($field_meta['callback'], $row, $field_meta['fields']); - } - if (is_array($callback_response)) { - $val = $callback_response['value']; - } else { - $val = $callback_response; - } - } else { - $val = $row[$key]; - } - - $type = isset($function) ? 'number' : $field_meta['type']; - - $format = ''; - if (isset($meta['formats'][$field])) { - $format = $meta['formats'][$field]; - } elseif (isset($this->default_format[$type])) { - $format = $this->default_format[$type]; - } - - switch ($type) { - case '': - case null: - case 'text': - $val = json_encode((string) $val); - $formatted = null; - - break; - case 'number': - $val = (float) $val; - if (preg_match('/^num:(\d+)(.*)$/i', $format, $matches)) { - $digits = (int) $matches[1]; - $extras = $matches[2]; - if (is_array($extras) && (2 === count($extras))) { - $formatted = number_format($val, $digits, $extras[0], $extras[1]); - } else { - $formatted = number_format($val, $digits); - } - } elseif ('dollars' === $format) { - $formatted = '$'.number_format($val, 2); - } elseif ('percent' === $format) { - $formatted = number_format($val * 100, 1).'%'; - } else { - $formatted = sprintf($format, $val); - } - $val = json_encode($val); - - break; - case 'boolean': - $val = (bool) $val; - list($format_false, $format_true) = explode(':', $format, 2); - $formatted = $val ? $format_true : $format_false; - $val = json_encode((bool) $val); - - break; - case 'date': - if (!is_numeric($val) || 6 !== strlen($val)) { - $time = strtotime($val); - list($year, $month, $day) = explode('-', date('Y-m-d', $time)); - $formatted = date($format, $time); - } else { - $year = substr($val, 0, 4); - $week = substr($val, -2); - $time = strtotime($year.'0104 +'.$week.' weeks'); - $monday = strtotime('-'.((int) date('w', $time) - 1).' days', $time); - list($year, $month, $day) = explode('-', date('Y-m-d', $monday)); - $formatted = date($format, $monday); - } - $val = 'new Date('.(int) $year.','.((int) $month - 1).','.(int) $day.')'; - - break; - case 'datetime': - case 'timestamp': - $time = strtotime($val); - list($year, $month, $day, $hour, $minute, $second) = explode('-', date('Y-m-d-H-i-s', $time)); - // MALC - Force us to consider the date as UTC... - $val = 'new Date(Date.UTC('.(int) $year.','.((int) $month - 1).','.(int) $day.','.(int) $hour.','.(int) $minute.','.(int) $second.'))'; - $formatted = date($format, $time); - - break; - case 'time': - $time = strtotime($val); - list($hour, $minute, $second) = explode('-', date('H-i-s', $time)); - $val = '['.(int) $hour.','.(int) $minute.','.(int) $second.',0]'; - $formatted = date($format, $time); - - break; - case 'binary': - $formatted = '0x'.current(unpack('H*', $val)); - $val = '0x'.current(unpack('H*', $val)); - $val = json_encode($val); - - break; - default: - throw new Visualization_Error('Unknown field type "'.$type.'"'); - } - - if (isset($callback_response['formatted'])) { - $formatted = $callback_response['formatted']; - } - - if (!isset($meta['options']['no_values'])) { - $cell = '{v:'.$val; - if (!isset($meta['options']['no_format'])) { - if (null !== $formatted) { - $cell .= ',f:'.json_encode($formatted); - } - } - } else { - $cell = '{f:'.json_encode($formatted); - } - - $vals[] = $cell.'}'; - } - - return '{c:['.implode(',', $vals).']}'; - } - - public function getSuccessClose() + protected function getSuccessClose() { return ']}});'; } - /** - * A utility method for testing - take a visualization query, and return the SQL that would be generated. - * - * @param string $query the visualization query to run - * - * @throws Visualization_QueryError - * @throws Visualization_Error - * @throws ParseError - * @throws DefError - * - * @return string the SQL that should be sent to the database - */ - public function getSQL($query) - { - $tokens = $this->parseQuery($query); - $meta = $this->generateMetadata($tokens); - - return $this->generateSQL($meta); - } - - /** - * Use MC_Parser to generate a grammar that matches the query language specified here: http://code.google.com/apis/visualization/documentation/querylanguage.html. - * - * @throws DefError - * - * @return Def the grammar for the query language - */ - public function getGrammar() - { - $p = new Parser(); - $ident = $p->oneOf( - $p->word($p->alphas().'_', $p->alphanums().'_'), - $p->quotedString('`') - ); - - $literal = $p->oneOf( - $p->number()->name('number'), - $p->hexNumber()->name('number'), - $p->quotedString()->name('string'), - $p->boolean('lower')->name('boolean'), - $p->set($p->keyword('date', true), $p->quotedString())->name('date'), - $p->set($p->keyword('timeofday', true), $p->quotedString())->name('time'), - $p->set( - $p->oneOf( - $p->keyword('datetime', true), - $p->keyword('timestamp', true) - ), - $p->quotedString() - )->name('datetime') - ); - - $function = $p->set($p->oneOf($p->literal('min', true), $p->literal('max', true), $p->literal('count', true), $p->literal('avg', true), $p->literal('sum', true))->name('func_name'), $p->literal('(')->suppress(), $ident, $p->literal(')')->suppress())->name('function'); - - $select = $p->set($p->keyword('select', true), $p->oneOf($p->keyword('*'), $p->delimitedList($p->oneOf($function, $ident))))->name('select'); - $from = $p->set($p->keyword('from', true), $ident)->name('from'); - - // Malc - Added 'Like' 20130219 - $comparison = $p->oneOf($p->literal('like'), $p->literal('<'), $p->literal('<='), $p->literal('>'), $p->literal('>='), $p->literal('='), $p->literal('!='), $p->literal('<>'))->name('operator'); - - $expr = $p->recursive(); - $value = $p->oneOf($literal, $ident->name('where_field')); - $cond = $p->oneOf( - $p->set($value, $comparison, $value), - $p->set($value, $p->set($p->keyword('is', true), $p->literal('null', true))->name('isnull')), - $p->set($value, $p->set($p->keyword('is', true), $p->keyword('not', true), $p->literal('null', true))->name('notnull')), - $p->set($p->literal('(')->name('sep'), $expr, $p->literal(')')->name('sep')) - ); - - $andor = $p->oneOf($p->keyword('and', true), $p->keyword('or', true))->name('andor_sep'); - - $expr->replace($p->set($cond, $p->zeroOrMore($p->set($andor, $expr)))); - - $where = $p->set($p->keyword('where', true), $expr)->name('where'); - - $groupby = $p->set($p->keyword('group', true), $p->keyword('by', true), $p->delimitedList($ident))->name('groupby'); - $pivot = $p->set($p->keyword('pivot', true), $p->delimitedList($ident))->name('pivot'); - - $orderby_clause = $p->set($ident, $p->optional($p->oneOf($p->literal('asc', true), $p->literal('desc', true)))); - $orderby = $p->set($p->keyword('order', true), $p->keyword('by', true), $p->delimitedList($orderby_clause))->name('orderby'); - $limit = $p->set($p->keyword('limit', true), $p->word($p->nums()))->name('limit'); - $offset = $p->set($p->keyword('offset', true), $p->word($p->nums()))->name('offset'); - $label = $p->set($p->keyword('label', true), $p->delimitedList($p->set($ident, $p->quotedString())))->name('label'); - $format = $p->set($p->keyword('format', true), $p->delimitedList($p->set($ident, $p->quotedString())))->name('format'); - $options = $p->set($p->keyword('options', true), $p->delimitedList($p->word($p->alphas().'_')))->name('options'); - - return $p->set($p->optional($select), $p->optional($from), $p->optional($where), $p->optional($groupby), $p->optional($pivot), $p->optional($orderby), $p->optional($limit), $p->optional($offset), $p->optional($label), $p->optional($format), $p->optional($options)); - } - /** * Convert a visualization date into the appropriate date-literal format for the SQL dialect. *