有时候会遇到这样的问题,在设计一个表用来存储不固定数目属性的数据时候,为了使得表能够保持足够的灵活性,经常会采取key/value这样的表结构方式。比如说有如下一个问题,我要设计一个程序使得用户可以自由设置一个对象可以有多少种属性,那么这种情况下我在设计表的时候是不知道有多少种属性要存储,也就是说这个表的列数是不好确定下来的。因此很常见的一种方式就是把这种不确定的表的列数转换成行来存储,列就变成了“generic”的了。
table 1: (object table)
---------------------------
| object_id | object_name |
---------------------------
table 2: (attribute table)
------------------------------------------------------------------
| attribute_id | attribute_name | attribute_data_type | object_id
------------------------------------------------------------------
table 3: (attribute value table)
------------------------------------------------------------
| attribute_id | number_value | varchar_value | date_value|
------------------------------------------------------------
这种表设计方式的最大好处就是存储灵活性,但是问题也很突出,就是数据查询不直观。这里面不讨论这种数据表设计的优劣,主要是想讨论下对于如下的需求怎么来实现,
- 为每个object都生成一个具体的表,每个object有多少属性就要生成多少个column, column的名字要跟attribute的名字类似。
很显然,这种情况下static SQL是很难办到的,需要用到动态SQL来将每个object的属性拼接出来创建表。大概的思路可以表示如下,
[dynamic create table sql] := 'CREATE TABLE ' || [OBJECT NAME] || [COLUMN LIST] || ' AS SELECT ' || [column list value] || ' FROM DUAL';
这里面[object name], [column list]都相对好得到,[column list]的拼接可以借助COLLECT函数来做,而[column list value]如果想从table 3: attribute value table中直接获取看起来不是一件很容易办到的事情,不过可以换一种思路,分成两步走。首先把表创建出来,然后再往这张表里面插入数据。那么在第一步中[column list value] 可以用hard code的方式,不过需要注意的是数据类型要跟对应的attribute对应上,不能都是varchar2类型。类型信息可以 table 2: attribute table中的attribute_data_type得到。
大概可以用如下伪SQL表示,
select
[object id],
table_string(cast(collect([attribute name] order by [attribute id]) as t_v4000_table)) as column_list,
table_string(
cast(collect(
(case [attribute data type]
when 'NUMBER' then q'[to_number('0')]'
when 'VARCHAR2' then q'[rpad('x', 4000, 'x')]'
when 'DATE' then q'[to_date('2010/01/01', 'yyyy/mm/dd')]'
end) order by [attribute id]) as t_v4000_table)) as column_init_value
from
[attribute table]group by[object id]
注意一下,这里面用到的table_string, t_v4000_table都是需要自己定义的,前者是用来把一个string collection转换成一个string,实现起来不是很难,这里就不赘述了; 后者自然是一个type的定义了,是varchar2(4000)的一个数组。这里面用到的collect函数,在以前的博客中提到过,参见这里。
这里面还有个值得注意的地方,
case [attribute data type]
when 'NUMBER' then q'[to_number('0')]'
when 'VARCHAR2' then q'[rpad('x', 4000, 'x')]'
when 'DATE' then q'[to_date('2010/01/01', 'yyyy/mm/dd')]'
end
因为case语句要求每个分支(when)的类型要一直,因此这里都统一成varchar的, 但是因为要保持每个attribute 的本来的类型信息,因此这儿需要用函数to_number, to_date来进行转换!
OK, 说了半天还没有说到正题上,也就是sqlchar 和correct_sql_name函数。
1. 首先来说下correct_sql_name function, 如下所示:
FUNCTION CORRECT_SQL_NAME ( VV_SQL_NAME IN VARCHAR2 ) RETURN VARCHAR2 DETERMINISTIC IS
v_authorized_char varchar2(100) := '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_$#';
v_corrected_name varchar2(30) := null;
begin
for i in 1..length(vv_sql_name) loop
if instr(v_authorized_char, substr(upper(vv_sql_name), i, 1)) = 0 then
v_corrected_name := v_corrected_name || '_';
else
v_corrected_name := v_corrected_name || substr(vv_sql_name, i, 1);
end if;
end loop;
return v_corrected_name;
end correct_sql_name;
我们知道数据库表和列的名字不是任何字符都允许的,而我们要创建的object表的列是根据object 的attribute name来确定的,attribute name 不太可能总是满足数据库列的名字要求,因此需要进行一定的转换,那么这个correct_sql_name的目的就是如此了。
因此上面的伪SQL中的如下一行SQL代码,
table_string(cast(collect([attribute name] order by [attribute id]) as t_v4000_table)
应该改成...
table_string(cast(collect(correct_sql_name([attribute name]) order by [attribute id]) as t_v4000_table)
2. SQLCHAR函数
function sqlchar(vv_in_str in varchar2, vv_accept_unicode in varchar2 default 'Y') return varchar2 is
/**
*======================================================================================<br/>
* Function: SQLCHAR. <br/>
* Description: Convert a literal string to a string that can be used directly in dynamic SQL statement, like 'a' to '''a''' <br/>
* @param vv_in_str the input string that to be converted
* @param vv_accept_unicode Y to get a input string like UNISTR('\0000')
* N to raise an exception
* Default to Y
* @return string
*/
v_return_str varchar2(32767);
v_len pls_integer;
v_char_index pls_integer;
v_row_curr_length pls_integer :=0;
v_row_length pls_integer := 128;
v_has_unicode boolean:=false;
v_char char(1 char); -- attention: explicitly state the semantics to char, as one charater could be larger than one byte
v_unicode_char varchar2(5);
begin
if vv_in_str is null then return 'NULL'; end if;
v_len:=length(vv_in_str);
for v_char_index in 1..v_len loop
-- string too big to fit on one line, we inserted a carriage return
if v_row_curr_length >= v_row_length then
v_return_str := v_return_str || '''||' ||chr(10) || ''''; -- attention: we inserted a chr (10) and not chr (13) + chr (10)
v_row_curr_length:=0;
end if;
v_char:=substr(vv_in_str,v_char_index,1);
-- single quote => double quote
if v_char = '''' then
v_return_str := v_return_str || '''''';
v_row_curr_length := v_row_curr_length + 2;
-- for invisible character (control character) and special substitution character(chr (38) = '&')
elsif v_char < ' ' or v_char = chr(38) then
v_return_str := v_return_str || '''||chr(' || ltrim(to_char(ascii(v_char),'00')) || ')||''';
v_row_curr_length := v_row_curr_length + 13;
elsif v_char = '\' then
if upper(vv_accept_unicode) = 'Y' then
v_has_unicode:=true;
v_return_str := v_return_str || '\005C'; -- asciistr('\') = '\005C'
v_row_curr_length := v_row_curr_length + 5;
else
v_return_str := v_return_str || '\';
v_row_curr_length := v_row_curr_length + 1;
end if;
else
v_unicode_char := asciistr(v_char);
if v_unicode_char <> v_char then
if upper(vv_accept_unicode) = 'Y' then
v_has_unicode:=true;
v_return_str :=v_return_str ||v_unicode_char;
v_row_curr_length := v_row_curr_length + 5;
else
raise_application_error(-20000, 'String contains some characters with ascii code > 127: "' || v_char || '" (' || asciistr(v_char) || ', ' || ascii(v_char) || ').');
end if;
else
v_return_str := v_return_str || v_char;
v_row_curr_length := v_row_curr_length + 1;
end if;
end if;
end loop;
if v_has_unicode then
return '''''||unistr('''||v_return_str||''')||'''''; -- Convert the liter string in National character set.
else
return '''' || v_return_str || '''';
end if;
end sqlchar;
function sqlchar(vv_in_number in number) return varchar2 is
begin
if vv_in_number is null then
return 'NULL';
else
return 'to_number(to_char(' || vv_in_number || '))';
end if;
end;
function sqlchar(vv_in_date in date) return varchar2 is
begin
if vv_in_date is null then
return 'NULL';
else
return 'to_date('''||to_char(vv_in_date,'YYYYMMDDHH24MISS') ||''',''YYYYMMDDHH24MISS'')';
end if;
end;
这里面用的unistr 和 asciistr函数可以分别参照http://download.oracle.com/docs/cd/B19306_01/server.102/b14200/functions204.htm#SQLRF06154 和 http://download.oracle.com/docs/cd/B19306_01/server.102/b14200/functions006.htm#SQLRF00605
在来看看上面提到的case 语句,
case [attribute data type]
when 'NUMBER' then q'[to_number('0')]'
when 'VARCHAR2' then q'[rpad('x', 4000, 'x')]'
when 'DATE' then q'[to_date('2010/01/01', 'yyyy/mm/dd')]'
end
用了类似 q'[to_number('0')]' 的转换操作,当然对于创建表结构的hard code这种简单的case来说,直接这么写是没有多大问题的。但是等到我们要往这些表中插入数据的时候,因为我们不知道attribute value是什么样子的,特别是varchar2类型的,直接在attribute value两侧加个单引号,很有可能不能正常work, 比如说attribute value中含有特殊字符'&'。我们知道'&'是个替代字符,在执行的时候会提示用户输入变量值,因此需要对这种字符进行“转义”操作。因此最好把上面的case语句重写为如下形式:
case [attribute data type]
when 'NUMBER' then sqlchar(0)
when 'VARCHAR2' then sqlchar(rpad('x', 4000, 'x'))
when 'DATE' then sqlchar(sysdate)
end
简单总结下
本文列出的这个问题还算是比较常见的。因此在解决类似的需要用动态SQL来创建表的时候,最好多想想可能会出现的一些问题。这里面提到的两个函数可以帮助避免一些容易忽略的问题。