Commit 13987057 authored by pem@mysql.comhem.se's avatar pem@mysql.comhem.se

WL#1366: Use the schema (db) associated with an SP.

Phase 3: Made qualified names work for functions as well.
parent 786f8232
...@@ -10,14 +10,27 @@ insert into db1_secret.t1 values (user(), i); ...@@ -10,14 +10,27 @@ insert into db1_secret.t1 values (user(), i);
show procedure status like 'stamp'; show procedure status like 'stamp';
Db Name Type Definer Modified Created Security_type Comment Db Name Type Definer Modified Created Security_type Comment
db1_secret stamp PROCEDURE root@localhost 0000-00-00 00:00:00 0000-00-00 00:00:00 DEFINER db1_secret stamp PROCEDURE root@localhost 0000-00-00 00:00:00 0000-00-00 00:00:00 DEFINER
create function db() returns varchar(64) return database();
show function status like 'db';
Db Name Type Definer Modified Created Security_type Comment
db1_secret db FUNCTION root@localhost 0000-00-00 00:00:00 0000-00-00 00:00:00 DEFINER
call stamp(1); call stamp(1);
select * from t1; select * from t1;
u i u i
root@localhost 1 root@localhost 1
select db();
db()
db1_secret
call db1_secret.stamp(2); call db1_secret.stamp(2);
select db1_secret.db();
db1_secret.db()
db1_secret
select * from db1_secret.t1; select * from db1_secret.t1;
ERROR 42000: Access denied for user: 'user1'@'localhost' to database 'db1_secret' ERROR 42000: Access denied for user: 'user1'@'localhost' to database 'db1_secret'
call db1_secret.stamp(3); call db1_secret.stamp(3);
select db1_secret.db();
db1_secret.db()
db1_secret
select * from db1_secret.t1; select * from db1_secret.t1;
ERROR 42000: Access denied for user: ''@'localhost' to database 'db1_secret' ERROR 42000: Access denied for user: ''@'localhost' to database 'db1_secret'
select * from t1; select * from t1;
...@@ -29,6 +42,10 @@ alter procedure stamp sql security invoker; ...@@ -29,6 +42,10 @@ alter procedure stamp sql security invoker;
show procedure status like 'stamp'; show procedure status like 'stamp';
Db Name Type Definer Modified Created Security_type Comment Db Name Type Definer Modified Created Security_type Comment
db1_secret stamp PROCEDURE root@localhost 0000-00-00 00:00:00 0000-00-00 00:00:00 INVOKER db1_secret stamp PROCEDURE root@localhost 0000-00-00 00:00:00 0000-00-00 00:00:00 INVOKER
alter function db sql security invoker;
show function status like 'db';
Db Name Type Definer Modified Created Security_type Comment
db1_secret db FUNCTION root@localhost 0000-00-00 00:00:00 0000-00-00 00:00:00 INVOKER
call stamp(4); call stamp(4);
select * from t1; select * from t1;
u i u i
...@@ -36,10 +53,17 @@ root@localhost 1 ...@@ -36,10 +53,17 @@ root@localhost 1
user1@localhost 2 user1@localhost 2
anon@localhost 3 anon@localhost 3
root@localhost 4 root@localhost 4
select db();
db()
db1_secret
call db1_secret.stamp(5); call db1_secret.stamp(5);
ERROR 42000: Access denied for user: 'user1'@'localhost' to database 'db1_secret' ERROR 42000: Access denied for user: 'user1'@'localhost' to database 'db1_secret'
select db1_secret.db();
ERROR 42000: Access denied for user: 'user1'@'localhost' to database 'db1_secret'
call db1_secret.stamp(6); call db1_secret.stamp(6);
ERROR 42000: Access denied for user: ''@'localhost' to database 'db1_secret' ERROR 42000: Access denied for user: ''@'localhost' to database 'db1_secret'
select db1_secret.db();
ERROR 42000: Access denied for user: ''@'localhost' to database 'db1_secret'
drop database if exists db2; drop database if exists db2;
create database db2; create database db2;
use db2; use db2;
...@@ -74,6 +98,7 @@ s1 ...@@ -74,6 +98,7 @@ s1
2 2
2 2
drop procedure db1_secret.stamp; drop procedure db1_secret.stamp;
drop function db1_secret.db;
drop procedure db2.p; drop procedure db2.p;
drop procedure db2.q; drop procedure db2.q;
use test; use test;
......
...@@ -21,15 +21,20 @@ use db1_secret; ...@@ -21,15 +21,20 @@ use db1_secret;
create table t1 ( u varchar(64), i int ); create table t1 ( u varchar(64), i int );
# Our test procedure # A test procedure and function
create procedure stamp(i int) create procedure stamp(i int)
insert into db1_secret.t1 values (user(), i); insert into db1_secret.t1 values (user(), i);
--replace_column 5 '0000-00-00 00:00:00' 6 '0000-00-00 00:00:00' --replace_column 5 '0000-00-00 00:00:00' 6 '0000-00-00 00:00:00'
show procedure status like 'stamp'; show procedure status like 'stamp';
create function db() returns varchar(64) return database();
--replace_column 5 '0000-00-00 00:00:00' 6 '0000-00-00 00:00:00'
show function status like 'db';
# root can, of course # root can, of course
call stamp(1); call stamp(1);
select * from t1; select * from t1;
select db();
connect (con2user1,localhost,user1,,); connect (con2user1,localhost,user1,,);
connect (con3anon,localhost,anon,,); connect (con3anon,localhost,anon,,);
...@@ -41,6 +46,7 @@ connection con2user1; ...@@ -41,6 +46,7 @@ connection con2user1;
# This should work... # This should work...
call db1_secret.stamp(2); call db1_secret.stamp(2);
select db1_secret.db();
# ...but not this # ...but not this
--error 1044 --error 1044
...@@ -53,6 +59,7 @@ connection con3anon; ...@@ -53,6 +59,7 @@ connection con3anon;
# This should work... # This should work...
call db1_secret.stamp(3); call db1_secret.stamp(3);
select db1_secret.db();
# ...but not this # ...but not this
--error 1044 --error 1044
...@@ -71,9 +78,14 @@ alter procedure stamp sql security invoker; ...@@ -71,9 +78,14 @@ alter procedure stamp sql security invoker;
--replace_column 5 '0000-00-00 00:00:00' 6 '0000-00-00 00:00:00' --replace_column 5 '0000-00-00 00:00:00' 6 '0000-00-00 00:00:00'
show procedure status like 'stamp'; show procedure status like 'stamp';
alter function db sql security invoker;
--replace_column 5 '0000-00-00 00:00:00' 6 '0000-00-00 00:00:00'
show function status like 'db';
# root still can # root still can
call stamp(4); call stamp(4);
select * from t1; select * from t1;
select db();
# #
# User1 cannot # User1 cannot
...@@ -83,6 +95,8 @@ connection con2user1; ...@@ -83,6 +95,8 @@ connection con2user1;
# This should not work # This should not work
--error 1044 --error 1044
call db1_secret.stamp(5); call db1_secret.stamp(5);
--error 1044
select db1_secret.db();
# #
# Anonymous cannot # Anonymous cannot
...@@ -92,7 +106,8 @@ connection con3anon; ...@@ -92,7 +106,8 @@ connection con3anon;
# This should not work # This should not work
--error 1044 --error 1044
call db1_secret.stamp(6); call db1_secret.stamp(6);
--error 1044
select db1_secret.db();
# #
# BUG#2777 # BUG#2777
...@@ -149,6 +164,7 @@ select * from t2; ...@@ -149,6 +164,7 @@ select * from t2;
# Clean up # Clean up
connection con1root; connection con1root;
drop procedure db1_secret.stamp; drop procedure db1_secret.stamp;
drop function db1_secret.db;
drop procedure db2.p; drop procedure db2.p;
drop procedure db2.q; drop procedure db2.q;
use test; use test;
......
...@@ -3120,7 +3120,11 @@ Item_func_sp::execute(Item **itp) ...@@ -3120,7 +3120,11 @@ Item_func_sp::execute(Item **itp)
if (! m_sp) if (! m_sp)
m_sp= sp_find_function(thd, m_name); m_sp= sp_find_function(thd, m_name);
if (! m_sp) if (! m_sp)
{
my_printf_error(ER_SP_DOES_NOT_EXIST, ER(ER_SP_DOES_NOT_EXIST), MYF(0),
"FUNCTION", m_name->m_qname);
DBUG_RETURN(-1); DBUG_RETURN(-1);
}
#ifndef NO_EMBEDDED_ACCESS_CHECKS #ifndef NO_EMBEDDED_ACCESS_CHECKS
sp_change_security_context(thd, m_sp, &save_ctx); sp_change_security_context(thd, m_sp, &save_ctx);
...@@ -3147,6 +3151,8 @@ Item_func_sp::field_type() const ...@@ -3147,6 +3151,8 @@ Item_func_sp::field_type() const
DBUG_PRINT("info", ("m_returns = %d", m_sp->m_returns)); DBUG_PRINT("info", ("m_returns = %d", m_sp->m_returns));
DBUG_RETURN(m_sp->m_returns); DBUG_RETURN(m_sp->m_returns);
} }
my_printf_error(ER_SP_DOES_NOT_EXIST, ER(ER_SP_DOES_NOT_EXIST), MYF(0),
"FUNCTION", m_name->m_qname);
DBUG_RETURN(MYSQL_TYPE_STRING); DBUG_RETURN(MYSQL_TYPE_STRING);
} }
...@@ -3162,6 +3168,8 @@ Item_func_sp::result_type() const ...@@ -3162,6 +3168,8 @@ Item_func_sp::result_type() const
{ {
DBUG_RETURN(m_sp->result()); DBUG_RETURN(m_sp->result());
} }
my_printf_error(ER_SP_DOES_NOT_EXIST, ER(ER_SP_DOES_NOT_EXIST), MYF(0),
"FUNCTION", m_name->m_qname);
DBUG_RETURN(STRING_RESULT); DBUG_RETURN(STRING_RESULT);
} }
...@@ -3172,7 +3180,12 @@ Item_func_sp::fix_length_and_dec() ...@@ -3172,7 +3180,12 @@ Item_func_sp::fix_length_and_dec()
if (! m_sp) if (! m_sp)
m_sp= sp_find_function(current_thd, m_name); m_sp= sp_find_function(current_thd, m_name);
if (m_sp) if (! m_sp)
{
my_printf_error(ER_SP_DOES_NOT_EXIST, ER(ER_SP_DOES_NOT_EXIST), MYF(0),
"FUNCTION", m_name->m_qname);
}
else
{ {
switch (m_sp->result()) { switch (m_sp->result()) {
case STRING_RESULT: case STRING_RESULT:
......
...@@ -1107,7 +1107,10 @@ public: ...@@ -1107,7 +1107,10 @@ public:
Item *it; Item *it;
if (execute(&it)) if (execute(&it))
{
null_value= 1;
return 0.0; return 0.0;
}
return it->val(); return it->val();
} }
...@@ -1116,7 +1119,10 @@ public: ...@@ -1116,7 +1119,10 @@ public:
Item *it; Item *it;
if (execute(&it)) if (execute(&it))
{
null_value= 1;
return NULL; return NULL;
}
return it->val_str(str); return it->val_str(str);
} }
......
...@@ -445,8 +445,7 @@ int mysql_rm_table_part2_with_lock(THD *thd, TABLE_LIST *tables, ...@@ -445,8 +445,7 @@ int mysql_rm_table_part2_with_lock(THD *thd, TABLE_LIST *tables,
int quick_rm_table(enum db_type base,const char *db, int quick_rm_table(enum db_type base,const char *db,
const char *table_name); const char *table_name);
bool mysql_rename_tables(THD *thd, TABLE_LIST *table_list); bool mysql_rename_tables(THD *thd, TABLE_LIST *table_list);
bool mysql_change_db(THD *thd,const char *name, bool mysql_change_db(THD *thd,const char *name);
bool empty_is_ok=0, bool no_access_check=0);
void mysql_parse(THD *thd,char *inBuf,uint length); void mysql_parse(THD *thd,char *inBuf,uint length);
bool is_update_query(enum enum_sql_command command); bool is_update_query(enum enum_sql_command command);
void free_items(Item *item); void free_items(Item *item);
......
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
#include "mysql_priv.h" #include "mysql_priv.h"
#include "sql_acl.h"
#include "sp.h" #include "sp.h"
#include "sp_head.h" #include "sp_head.h"
#include "sp_cache.h" #include "sp_cache.h"
...@@ -960,19 +961,104 @@ sp_use_new_db(THD *thd, char *newdb, char *olddb, uint olddblen, ...@@ -960,19 +961,104 @@ sp_use_new_db(THD *thd, char *newdb, char *olddb, uint olddblen,
} }
} }
/*
Change database.
SYNOPSIS
sp_change_db()
thd Thread handler
name Database name
empty_is_ok True= it's ok with "" as name
no_access_check True= don't do access check
DESCRIPTION
This is the same as mysql_change_db(), but with some extra
arguments for Stored Procedure usage; doing implicit "use"
when executing an SP in a different database.
We also use different error routines, since this might be
invoked from a function when executing a query or statement.
Note: We would have prefered to reuse mysql_change_db(), but
the error handling in particular made that too awkward, so
we (reluctantly) have a "copy" here.
RETURN VALUES
0 ok
1 error
*/
int int
sp_change_db(THD *thd, char *db, bool no_access_check) sp_change_db(THD *thd, char *name, bool no_access_check)
{ {
int ret; int length, db_length;
ulong dbaccess= thd->db_access; /* mysql_change_db() changes this */ char *dbname=my_strdup((char*) name,MYF(MY_WME));
my_bool nsok= thd->net.no_send_ok; /* mysql_change_db() does send_ok() */ char path[FN_REFLEN];
thd->net.no_send_ok= TRUE; ulong db_access;
HA_CREATE_INFO create;
DBUG_ENTER("sp_change_db"); DBUG_ENTER("sp_change_db");
DBUG_PRINT("enter", ("db: %s, no_access_check: %d", db, no_access_check)); DBUG_PRINT("enter", ("db: %s, no_access_check: %d", name, no_access_check));
ret= mysql_change_db(thd, db, 1, no_access_check); db_length= (!dbname ? 0 : strip_sp(dbname));
if (dbname && db_length)
{
if ((db_length > NAME_LEN) || check_db_name(dbname))
{
my_printf_error(ER_WRONG_DB_NAME, ER(ER_WRONG_DB_NAME), MYF(0), dbname);
x_free(dbname);
DBUG_RETURN(1);
}
}
thd->net.no_send_ok= nsok; if (dbname && db_length)
thd->db_access= dbaccess; {
DBUG_RETURN(ret); #ifndef NO_EMBEDDED_ACCESS_CHECKS
if (! no_access_check)
{
if (test_all_bits(thd->master_access,DB_ACLS))
db_access=DB_ACLS;
else
db_access= (acl_get(thd->host,thd->ip, thd->priv_user,dbname,0) |
thd->master_access);
if (!(db_access & DB_ACLS) &&
(!grant_option || check_grant_db(thd,dbname)))
{
my_printf_error(ER_DBACCESS_DENIED_ERROR, ER(ER_DBACCESS_DENIED_ERROR),
MYF(0),
thd->priv_user,
thd->priv_host,
dbname);
mysql_log.write(thd,COM_INIT_DB,ER(ER_DBACCESS_DENIED_ERROR),
thd->priv_user,
thd->priv_host,
dbname);
my_free(dbname,MYF(0));
DBUG_RETURN(1);
}
}
#endif
(void) sprintf(path,"%s/%s",mysql_data_home,dbname);
length=unpack_dirname(path,path); // Convert if not unix
if (length && path[length-1] == FN_LIBCHAR)
path[length-1]=0; // remove ending '\'
if (access(path,F_OK))
{
my_printf_error(ER_BAD_DB_ERROR, ER(ER_BAD_DB_ERROR), MYF(0), dbname);
my_free(dbname,MYF(0));
DBUG_RETURN(1);
}
}
x_free(thd->db);
thd->db=dbname; // THD::~THD will free this
thd->db_length=db_length;
if (dbname && db_length)
{
strmov(path+unpack_dirname(path,path), MY_DB_OPT_FILE);
load_db_opt(thd, path, &create);
thd->db_charset= create.default_table_charset ?
create.default_table_charset :
thd->variables.collation_server;
thd->variables.collation_database= thd->db_charset;
}
DBUG_RETURN(0);
} }
...@@ -389,12 +389,13 @@ sp_head::execute(THD *thd) ...@@ -389,12 +389,13 @@ sp_head::execute(THD *thd)
continue; continue;
} }
} }
} while (ret == 0 && !thd->killed && !thd->query_error); } while (ret == 0 && !thd->killed && !thd->query_error &&
!thd->net.report_error);
done: done:
DBUG_PRINT("info", ("ret=%d killed=%d query_error=%d", DBUG_PRINT("info", ("ret=%d killed=%d query_error=%d",
ret, thd->killed, thd->query_error)); ret, thd->killed, thd->query_error));
if (thd->killed || thd->query_error) if (thd->killed || thd->query_error || thd->net.report_error)
ret= -1; ret= -1;
/* If the DB has changed, the pointer has changed too, but the /* If the DB has changed, the pointer has changed too, but the
original thd->db will then have been freed */ original thd->db will then have been freed */
...@@ -553,7 +554,12 @@ sp_head::execute_procedure(THD *thd, List<Item> *args) ...@@ -553,7 +554,12 @@ sp_head::execute_procedure(THD *thd, List<Item> *args)
ret= execute(thd); ret= execute(thd);
// Don't copy back OUT values if we got an error // Don't copy back OUT values if we got an error
if (ret == 0 && csize > 0) if (ret)
{
if (thd->net.report_error)
send_error(thd, 0, NullS);
}
else if (csize > 0)
{ {
List_iterator_fast<Item> li(*args); List_iterator_fast<Item> li(*args);
Item *it; Item *it;
......
...@@ -595,8 +595,7 @@ static long mysql_rm_known_files(THD *thd, MY_DIR *dirp, const char *db, ...@@ -595,8 +595,7 @@ static long mysql_rm_known_files(THD *thd, MY_DIR *dirp, const char *db,
1 error 1 error
*/ */
bool mysql_change_db(THD *thd, const char *name, bool mysql_change_db(THD *thd, const char *name)
bool empty_is_ok, bool no_access_check)
{ {
int length, db_length; int length, db_length;
char *dbname=my_strdup((char*) name,MYF(MY_WME)); char *dbname=my_strdup((char*) name,MYF(MY_WME));
...@@ -605,34 +604,26 @@ bool mysql_change_db(THD *thd, const char *name, ...@@ -605,34 +604,26 @@ bool mysql_change_db(THD *thd, const char *name,
HA_CREATE_INFO create; HA_CREATE_INFO create;
DBUG_ENTER("mysql_change_db"); DBUG_ENTER("mysql_change_db");
if ((!dbname || !(db_length=strip_sp(dbname))) && !empty_is_ok) if (!dbname || !(db_length=strip_sp(dbname)))
{ {
x_free(dbname); /* purecov: inspected */ x_free(dbname); /* purecov: inspected */
send_error(thd,ER_NO_DB_ERROR); /* purecov: inspected */ send_error(thd,ER_NO_DB_ERROR); /* purecov: inspected */
DBUG_RETURN(1); /* purecov: inspected */ DBUG_RETURN(1); /* purecov: inspected */
} }
if (!empty_is_ok || (dbname && db_length))
{
if ((db_length > NAME_LEN) || check_db_name(dbname)) if ((db_length > NAME_LEN) || check_db_name(dbname))
{ {
net_printf(thd, ER_WRONG_DB_NAME, dbname); net_printf(thd, ER_WRONG_DB_NAME, dbname);
x_free(dbname); x_free(dbname);
DBUG_RETURN(1); DBUG_RETURN(1);
} }
}
DBUG_PRINT("info",("Use database: %s", dbname)); DBUG_PRINT("info",("Use database: %s", dbname));
if (!empty_is_ok || (dbname && db_length))
{
#ifndef NO_EMBEDDED_ACCESS_CHECKS #ifndef NO_EMBEDDED_ACCESS_CHECKS
if (! no_access_check)
{
if (test_all_bits(thd->master_access,DB_ACLS)) if (test_all_bits(thd->master_access,DB_ACLS))
db_access=DB_ACLS; db_access=DB_ACLS;
else else
db_access= (acl_get(thd->host,thd->ip, thd->priv_user,dbname,0) | db_access= (acl_get(thd->host,thd->ip, thd->priv_user,dbname,0) |
thd->master_access); thd->master_access);
if (!(db_access & DB_ACLS) && if (!(db_access & DB_ACLS) && (!grant_option || check_grant_db(thd,dbname)))
(!grant_option || check_grant_db(thd,dbname)))
{ {
net_printf(thd,ER_DBACCESS_DENIED_ERROR, net_printf(thd,ER_DBACCESS_DENIED_ERROR,
thd->priv_user, thd->priv_user,
...@@ -645,7 +636,6 @@ bool mysql_change_db(THD *thd, const char *name, ...@@ -645,7 +636,6 @@ bool mysql_change_db(THD *thd, const char *name,
my_free(dbname,MYF(0)); my_free(dbname,MYF(0));
DBUG_RETURN(1); DBUG_RETURN(1);
} }
}
#endif #endif
(void) sprintf(path,"%s/%s",mysql_data_home,dbname); (void) sprintf(path,"%s/%s",mysql_data_home,dbname);
length=unpack_dirname(path,path); // Convert if not unix length=unpack_dirname(path,path); // Convert if not unix
...@@ -657,15 +647,11 @@ bool mysql_change_db(THD *thd, const char *name, ...@@ -657,15 +647,11 @@ bool mysql_change_db(THD *thd, const char *name,
my_free(dbname,MYF(0)); my_free(dbname,MYF(0));
DBUG_RETURN(1); DBUG_RETURN(1);
} }
}
send_ok(thd); send_ok(thd);
x_free(thd->db); x_free(thd->db);
thd->db=dbname; // THD::~THD will free this thd->db=dbname; // THD::~THD will free this
thd->db_length=db_length; thd->db_length=db_length;
if (!empty_is_ok || (dbname && db_length))
{
#ifndef NO_EMBEDDED_ACCESS_CHECKS #ifndef NO_EMBEDDED_ACCESS_CHECKS
if (! no_access_check)
thd->db_access=db_access; thd->db_access=db_access;
#endif #endif
strmov(path+unpack_dirname(path,path), MY_DB_OPT_FILE); strmov(path+unpack_dirname(path,path), MY_DB_OPT_FILE);
...@@ -674,7 +660,6 @@ bool mysql_change_db(THD *thd, const char *name, ...@@ -674,7 +660,6 @@ bool mysql_change_db(THD *thd, const char *name,
create.default_table_charset : create.default_table_charset :
thd->variables.collation_server; thd->variables.collation_server;
thd->variables.collation_database= thd->db_charset; thd->variables.collation_database= thd->db_charset;
}
DBUG_RETURN(0); DBUG_RETURN(0);
} }
...@@ -3923,21 +3923,19 @@ simple_expr: ...@@ -3923,21 +3923,19 @@ simple_expr:
{ $$= new Item_func_round($3,$5,1); } { $$= new Item_func_round($3,$5,1); }
| TRUE_SYM | TRUE_SYM
{ $$= new Item_int((char*) "TRUE",1,1); } { $$= new Item_int((char*) "TRUE",1,1); }
| IDENT_sys '(' udf_expr_list ')' | ident '.' ident '(' udf_expr_list ')'
{
sp_name *name= sp_name_current_db_new(YYTHD, $1);
if (sp_function_exists(YYTHD, name))
{ {
LEX *lex= Lex; LEX *lex= Lex;
sp_name *name= new sp_name($1, $3);
sp_add_fun_to_lex(lex, name); name->init_qname(YYTHD);
if ($3) sp_add_fun_to_lex(Lex, name);
$$= new Item_func_sp(name, *$3); if ($5)
$$= new Item_func_sp(name, *$5);
else else
$$= new Item_func_sp(name); $$= new Item_func_sp(name);
} }
else | IDENT_sys '(' udf_expr_list ')'
{ {
#ifdef HAVE_DLOPEN #ifdef HAVE_DLOPEN
udf_func *udf; udf_func *udf;
...@@ -3997,7 +3995,16 @@ simple_expr: ...@@ -3997,7 +3995,16 @@ simple_expr:
YYABORT; YYABORT;
} }
} }
else
#endif /* HAVE_DLOPEN */ #endif /* HAVE_DLOPEN */
{
sp_name *name= sp_name_current_db_new(YYTHD, $1);
sp_add_fun_to_lex(Lex, name);
if ($3)
$$= new Item_func_sp(name, *$3);
else
$$= new Item_func_sp(name);
} }
} }
| UNIQUE_USERS '(' text_literal ',' NUM ',' NUM ',' expr_list ')' | UNIQUE_USERS '(' text_literal ',' NUM ',' NUM ',' expr_list ')'
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment