提交 34d36553 authored 作者: Thomas Mueller's avatar Thomas Mueller

--no commit message

--no commit message
上级 5d7da357
......@@ -40,7 +40,13 @@ Hypersonic SQL or HSQLDB. H2 is built from scratch.
<h3>Version 1.0 (Current)</h3>
<h3>Version 1.0.59 (2007-09-TODO)</h3><ul>
<li>When using a subquery with group by as a table, some columns could not be used in the where condition
<li>When creating a table using CREATE TABLE .. AS SELECT, the precision for some data types (for example VARCHAR)
was set to the default precision. Fixed.
</li><li>When using the (undocumented) in-memory file system (jdbc:h2:memFS:x or jdbc:h2:memLZF:x), and using
multiple connections, a ConcurrentModificationException could occur. Fixed.
</li><li>REGEXP compatibility: So far String.matches was used, but for compatibility with MySQL, now Matcher.find is used.
</li><li>SCRIPT: the SQL statements in the result set now include the terminating semicolon as well. Simplifies copy and paste.
</li><li>When using a subquery with group by as a table, some columns could not be used in the where condition
in the outer query. Example: SELECT * FROM (SELECT ID, COUNT(*) C FROM TEST) WHERE C > 100. Fixed.
</li><li>Views with subqueries as tables and queries with nested subqueries as tables did not always work. Fixed.
</li><li>Compatibility: comparing columns with constants that are out of range does not throw an exception.
......
......@@ -186,7 +186,8 @@ public class CreateTable extends SchemaCommand {
String name = expr.getColumnName();
long precision = expr.getPrecision();
DataType dt = DataType.getDataType(type);
if (precision > 0 && (dt.defaultPrecision == 0 || dt.defaultPrecision > precision)) {
if (precision > 0 && (dt.defaultPrecision == 0 || (dt.defaultPrecision > precision && dt.defaultPrecision < Byte.MAX_VALUE))) {
// dont' set precision to MAX_VALUE if this is the default
precision = dt.defaultPrecision;
}
int scale = expr.getScale();
......
......@@ -304,13 +304,9 @@ public class ScriptCommand extends ScriptBase {
private int writeLobStream(ValueLob v) throws IOException, SQLException {
if (!tempLobTableCreated) {
add(
"CREATE TABLE IF NOT EXISTS SYSTEM_LOB_STREAM(ID INT, PART INT, CDATA VARCHAR, BDATA BINARY, PRIMARY KEY(ID, PART))",
true);
add("CREATE ALIAS IF NOT EXISTS SYSTEM_COMBINE_CLOB FOR \"" + this.getClass().getName() + ".combineClob\"",
true);
add("CREATE ALIAS IF NOT EXISTS SYSTEM_COMBINE_BLOB FOR \"" + this.getClass().getName() + ".combineBlob\"",
true);
add("CREATE TABLE IF NOT EXISTS SYSTEM_LOB_STREAM(ID INT, PART INT, CDATA VARCHAR, BDATA BINARY, PRIMARY KEY(ID, PART))", true);
add("CREATE ALIAS IF NOT EXISTS SYSTEM_COMBINE_CLOB FOR \"" + this.getClass().getName() + ".combineClob\"", true);
add("CREATE ALIAS IF NOT EXISTS SYSTEM_COMBINE_BLOB FOR \"" + this.getClass().getName() + ".combineBlob\"", true);
tempLobTableCreated = true;
}
int id = nextLobId++;
......@@ -405,8 +401,9 @@ public class ScriptCommand extends ScriptBase {
if (s == null) {
return;
}
s += ";";
if (out != null) {
byte[] buff = StringUtils.utf8Encode(s + ";");
byte[] buff = StringUtils.utf8Encode(s);
int len = MathUtils.roundUp(buff.length + lineSeparator.length, Constants.FILE_BLOCK_SIZE);
buffer = ByteUtils.copy(buff, buffer);
......
......@@ -8,6 +8,8 @@ import java.sql.SQLException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Properties;
import org.h2.api.DatabaseEventListener;
import org.h2.command.dml.SetTypes;
import org.h2.constant.ErrorCode;
import org.h2.constant.SysProperties;
......@@ -164,6 +166,17 @@ public class ConnectionInfo {
}
}
}
DatabaseEventListener removeDatabaseEventListenerObject() throws SQLException {
Object p = prop.remove("DATABASE_EVENT_LISTENER_OBJECT");
if (p == null) {
return null;
}
if (p instanceof DatabaseEventListener) {
return (DatabaseEventListener) p;
}
throw Message.getSQLException(ErrorCode.DATA_CONVERSION_ERROR_1, p.getClass().getName());
}
private char[] removePassword() {
Object p = prop.remove("PASSWORD");
......
......@@ -153,15 +153,18 @@ public class Database implements DataHandler {
this.fileLockMethod = FileLock.getFileLockMethod(lockMethodName);
this.textStorage = ci.getTextStorage();
this.databaseURL = ci.getURL();
String listener = ci.removeProperty("DATABASE_EVENT_LISTENER", null);
if (listener != null) {
if (listener.startsWith("'")) {
listener = listener.substring(1);
}
if (listener.endsWith("'")) {
listener = listener.substring(0, listener.length() - 1);
this.eventListener = ci.removeDatabaseEventListenerObject();
if (eventListener == null) {
String listener = ci.removeProperty("DATABASE_EVENT_LISTENER", null);
if (listener != null) {
if (listener.startsWith("'")) {
listener = listener.substring(1);
}
if (listener.endsWith("'")) {
listener = listener.substring(0, listener.length() - 1);
}
setEventListener(listener);
}
setEventListener(listener);
}
String log = ci.getProperty(SetTypes.LOG, null);
if (log != null) {
......
......@@ -203,7 +203,8 @@ public class CompareLike extends Condition {
String value = l.getString();
boolean result;
if (regexp) {
result = patternRegexp.matcher(value).matches();
// result = patternRegexp.matcher(value).matches();
result = patternRegexp.matcher(value).find();
} else {
result = compareAt(value, 0, 0, value.length());
}
......
......@@ -195,7 +195,7 @@ public class FullText implements Trigger {
stat.execute("CREATE TABLE IF NOT EXISTS "+SCHEMA+".ROWS(ID IDENTITY, HASH INT, INDEXID INT, KEY VARCHAR, UNIQUE(HASH, INDEXID, KEY))");
// 3391, 1484
// stat.execute("CREATE TABLE IF NOT EXISTS "+SCHEMA+".MAP(ROWID INT, WORDID INT, UNIQUE(ROWID, WORDID), UNIQUE(WORDID, ROWID))");
// stat.execute("CREATE TABLE IF NOT EXISTS "+SCHEMA+".MAP(ROWID INT, WORDID INT, UNIQUE(ROWID, WORDID), UNIQUE(WORDID, ROWID))");
// 3063, 1484
stat.execute("CREATE TABLE IF NOT EXISTS "+SCHEMA+".MAP(ROWID INT, WORDID INT, PRIMARY KEY(WORDID, ROWID))");
......
......@@ -153,10 +153,6 @@ public class ViewIndex extends BaseIndex {
if (first != null) {
Value v = first.getValue(i);
if (v != null) {
if(paramList.size() <= idx) {
int tst;
System.out.println("stop!");
}
Parameter param = (Parameter) paramList.get(idx++);
param.setValue(v);
}
......
......@@ -1183,7 +1183,7 @@ compare { {{ALL|ANY|SOME}(select)} | operand }
","
The right hand side of a condition.
When comparing with LIKE, the wildcards characters are _ (any one character) and % (any characters).
When comparing with REGEXP, regular expression matching is used. See Java String.matches for details.
When comparing with REGEXP, regular expression matching is used. See Java Matcher.find for details.
","
LIKE 'Jo%'
"
......
......@@ -20,6 +20,7 @@ import java.util.Locale;
import java.util.Properties;
import java.util.TimeZone;
import org.h2.api.DatabaseEventListener;
import org.h2.constant.SysProperties;
import org.h2.engine.Constants;
import org.h2.message.TraceSystem;
......@@ -418,22 +419,23 @@ public class WebServer implements Service {
}
}
Connection getConnection(String driver, String url, String user, String password) throws Exception {
Connection getConnection(String driver, String url, String user, String password, DatabaseEventListener listener) throws Exception {
driver = driver.trim();
url = url.trim();
user = user.trim();
password = password.trim();
org.h2.Driver.load();
Properties p = new Properties();
p.setProperty("user", user.trim());
p.setProperty("password", password.trim());
if (url.startsWith("jdbc:h2:")) {
p.put("DATABASE_EVENT_LISTENER_OBJECT", listener);
}
// try {
// Driver dr = (Driver) urlClassLoader.loadClass(driver).newInstance();
// Properties p = new Properties();
// p.setProperty("user", user);
// p.setProperty("password", password);
// return dr.connect(url, p);
// } catch(ClassNotFoundException e2) {
// throw e2;
// }
return JdbcUtils.getConnection(driver, url, user, password);
return JdbcUtils.getConnection(driver, url, p);
}
void shutdown() {
......
......@@ -31,6 +31,7 @@ import java.util.Random;
import java.util.StringTokenizer;
import java.util.Map.Entry;
import org.h2.api.DatabaseEventListener;
import org.h2.bnf.Bnf;
import org.h2.message.TraceSystem;
import org.h2.tools.SimpleResultSet;
......@@ -43,7 +44,7 @@ import org.h2.util.ObjectArray;
import org.h2.util.ScriptReader;
import org.h2.util.StringUtils;
class WebThread extends Thread {
class WebThread extends Thread implements DatabaseEventListener {
private WebServer server;
private WebSession session;
private Properties attributes;
......@@ -53,6 +54,8 @@ class WebThread extends Thread {
private String ifModifiedSince;
private String mimeType;
private boolean cache;
private int listenerLastState;
private long listenerLastEvent;
// TODO web: support online data editing like http://numsum.com/
......@@ -800,7 +803,7 @@ class WebThread extends Thread {
session.put("url", url);
session.put("user", user);
try {
Connection conn = server.getConnection(driver, url, user, password);
Connection conn = server.getConnection(driver, url, user, password, this);
JdbcUtils.closeSilently(conn);
session.put("error", "${text.login.testSuccessful}");
return "index.jsp";
......@@ -824,7 +827,7 @@ class WebThread extends Thread {
String user = attributes.getProperty("user", "");
String password = attributes.getProperty("password", "");
try {
Connection conn = server.getConnection(driver, url, user, password);
Connection conn = server.getConnection(driver, url, user, password, this);
session.setConnection(conn);
session.put("url", url);
session.put("user", user);
......@@ -1563,4 +1566,57 @@ class WebThread extends Thread {
return session;
}
private void log(String s) {
int test;
System.out.println(s);
}
public void closingDatabase() {
log("Closing database");
}
public void diskSpaceIsLow(long stillAvailable) throws SQLException {
log("Disk space is low; still available: " + stillAvailable);
}
public void exceptionThrown(SQLException e, String sql) {
log("Exception: " + e.toString() + " SQL: " + sql);
}
public void init(String url) {
log("Init: " + url);
}
public void opened() {
log("Database was opened");
}
public void setProgress(int state, String name, int x, int max) {
if (state == listenerLastState) {
long time = System.currentTimeMillis();
if (listenerLastEvent + 500 < time) {
return;
}
listenerLastEvent = time;
} else {
listenerLastState = state;
}
switch(state) {
case DatabaseEventListener.STATE_BACKUP_FILE:
log("Backing up " + name + " " + (100L * max / x) + "%");
break;
case DatabaseEventListener.STATE_CREATE_INDEX:
log("Creating index " + name + " " + (100L * max / x) + "%");
break;
case DatabaseEventListener.STATE_RECOVER:
log("Recovering " + name + " " + (100L * max / x) + "%");
break;
case DatabaseEventListener.STATE_SCAN_FILE:
log("Scanning file " + name + " " + (100L * max / x) + "%");
break;
default:
log("Unknown state: " + state);
}
}
}
......@@ -132,7 +132,9 @@ public class FileUtils {
if (isInMemory(oldName)) {
MemoryFile f = getMemoryFile(oldName);
f.setName(newName);
MEMORY_FILES.put(newName, f);
synchronized (MEMORY_FILES) {
MEMORY_FILES.put(newName, f);
}
return;
}
File oldFile = new File(oldName);
......@@ -239,7 +241,9 @@ public class FileUtils {
public static void delete(String fileName) throws SQLException {
fileName = translateFileName(fileName);
if (isInMemory(fileName)) {
MEMORY_FILES.remove(fileName);
synchronized (MEMORY_FILES) {
MEMORY_FILES.remove(fileName);
}
return;
}
File file = new File(fileName);
......@@ -309,7 +313,9 @@ public class FileUtils {
public static void tryDelete(String fileName) {
fileName = translateFileName(fileName);
if (isInMemory(fileName)) {
MEMORY_FILES.remove(fileName);
synchronized (MEMORY_FILES) {
MEMORY_FILES.remove(fileName);
}
return;
}
trace("tryDelete", fileName, null);
......@@ -328,19 +334,23 @@ public class FileUtils {
public static boolean exists(String fileName) {
fileName = translateFileName(fileName);
if (isInMemory(fileName)) {
return MEMORY_FILES.get(fileName) != null;
synchronized (MEMORY_FILES) {
return MEMORY_FILES.get(fileName) != null;
}
}
return new File(fileName).exists();
}
public static MemoryFile getMemoryFile(String fileName) {
MemoryFile m = (MemoryFile) MEMORY_FILES.get(fileName);
if (m == null) {
boolean compress = fileName.startsWith(MEMORY_PREFIX_LZF);
m = new MemoryFile(fileName, compress);
MEMORY_FILES.put(fileName, m);
synchronized (MEMORY_FILES) {
MemoryFile m = (MemoryFile) MEMORY_FILES.get(fileName);
if (m == null) {
boolean compress = fileName.startsWith(MEMORY_PREFIX_LZF);
m = new MemoryFile(fileName, compress);
MEMORY_FILES.put(fileName, m);
}
return m;
}
return m;
}
public static long length(String fileName) {
......@@ -396,13 +406,15 @@ public class FileUtils {
public static String[] listFiles(String path) throws SQLException {
path = translateFileName(path);
if (isInMemory(path)) {
String[] list = new String[MEMORY_FILES.size()];
MemoryFile[] l = new MemoryFile[MEMORY_FILES.size()];
MEMORY_FILES.values().toArray(l);
for (int i = 0; i < list.length; i++) {
list[i] = l[i].getName();
synchronized (MEMORY_FILES) {
String[] list = new String[MEMORY_FILES.size()];
MemoryFile[] l = new MemoryFile[MEMORY_FILES.size()];
MEMORY_FILES.values().toArray(l);
for (int i = 0; i < list.length; i++) {
list[i] = l[i].getName();
}
return list;
}
return list;
}
File f = new File(path);
try {
......
......@@ -9,6 +9,7 @@ import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Properties;
//#ifdef JDK14
import javax.naming.Context;
......@@ -73,16 +74,25 @@ public class JdbcUtils {
//#endif
public static Connection getConnection(String driver, String url, String user, String password) throws SQLException {
Properties prop = new Properties();
prop.setProperty("user", user);
prop.setProperty("password", password);
return getConnection(driver, url, prop);
}
public static Connection getConnection(String driver, String url, Properties prop) throws SQLException {
if (!StringUtils.isNullOrEmpty(driver)) {
try {
Class d = ClassUtils.loadClass(driver);
if (java.sql.Driver.class.isAssignableFrom(d)) {
return DriverManager.getConnection(url, user, password);
return DriverManager.getConnection(url, prop);
} else if (javax.naming.Context.class.isAssignableFrom(d)) {
// JNDI context
try {
Context context = (Context) d.newInstance();
DataSource ds = (DataSource) context.lookup(url);
String user = prop.getProperty("user");
String password = prop.getProperty("password");
return ds.getConnection(user, password);
} catch (InstantiationException e) {
throw Message.convert(e);
......@@ -93,13 +103,13 @@ public class JdbcUtils {
}
} else {
// Don't know, but maybe it loaded a JDBC Driver
return DriverManager.getConnection(url, user, password);
return DriverManager.getConnection(url, prop);
}
} catch (ClassNotFoundException e) {
throw Message.getSQLException(ErrorCode.CLASS_NOT_FOUND_1, new String[]{driver}, e);
}
}
return DriverManager.getConnection(url, user, password);
return DriverManager.getConnection(url, prop);
}
}
......@@ -48,6 +48,7 @@ import org.h2.test.db.TestTwoPhaseCommit;
import org.h2.test.db.TestView;
import org.h2.test.jdbc.TestCancel;
import org.h2.test.jdbc.TestDataSource;
import org.h2.test.jdbc.TestDatabaseEventListener;
import org.h2.test.jdbc.TestManyJdbcObjects;
import org.h2.test.jdbc.TestMetaData;
import org.h2.test.jdbc.TestNativeSQL;
......@@ -143,82 +144,80 @@ java org.h2.test.TestAll timer
/*
DROP TABLE IF EXISTS TEST;
CREATE TABLE TEST(ID INT PRIMARY KEY, NAME VARCHAR(255));
INSERT INTO TEST VALUES(1, 'Hello');
INSERT INTO TEST VALUES(2, 'HelloHello');
SELECT * FROM TEST WHERE NAME REGEXP 'He';
java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextEntry(HashMap.java:841)
at java.util.HashMap$ValueIterator.next(HashMap.java:871)
at java.util.AbstractCollection.toArray(AbstractCollection.java:176)
at org.h2.util.FileUtils.listFiles(FileUtils.java:395)
at org.h2.engine.Database.deleteOldTempFiles(Database.java:1055)
at org.h2.engine.Database.closeOpenFilesAndUnlock(Database.java:853)
at org.h2.engine.Database.close(Database.java:814)
at org.h2.engine.Database.removeSession(Database.java:755)
at org.h2.engine.Session.close(Session.java:260)
at org.h2.engine.Database.close(Database.java:783)
SCRIPT: append ; also in result set (copy paste problem)
web page translation
Full Text Search
H2 supports Lucene full text search and native full text search implementation.
Using the Native Full Text Search
To initialize, call:
CREATE ALIAS IF NOT EXISTS FT_INIT FOR "org.h2.fulltext.FullText.init";
CALL FT_INIT();
Afterwards, you can create a full text index for a table using:
CREATE TABLE TEST(ID INT PRIMARY KEY, NAME VARCHAR);
INSERT INTO TEST VALUES(1, 'Hello World');
CALL FT_CREATE_INDEX('PUBLIC', 'TEST', NULL);
where PUBLIC is the schema, TEST is the table name. The list of column names (column separated) is optional, in this case all columns are indexed. The index is updated in read time. To search the index, use the following query:
SELECT * FROM FT_SEARCH('Hello', 0, 0);
You can also call the index from within a Java application:
org.h2.fulltext.FullText.search(conn, text, limit, offset)
Using the Lucene Full Text Search
To use the Lucene full text search, you first need to rename the file FullTextLucene.java.txt to FullTestLucene.java and compile it. Also, you need the Lucene library in the classpath.
To initialize, call:
CREATE ALIAS IF NOT EXISTS FTL_INIT FOR "org.h2.fulltext.FullTextLucene.init";
CALL FTL_INIT();
Afterwards, you can create a full text index for a table using:
CREATE TABLE TEST(ID INT PRIMARY KEY, NAME VARCHAR);
INSERT INTO TEST VALUES(1, 'Hello World');
CALL FTL_CREATE_INDEX('PUBLIC', 'TEST', NULL);
where PUBLIC is the schema, TEST is the table name. The list of column names (column separated) is optional, in this case all columns are indexed. The index is updated in read time. To search the index, use the following query:
SELECT * FROM FTL_SEARCH('Hello', 0, 0);
You can also call the index from within a Java application:
org.h2.fulltext.FullTextLucene.search(conn, text, limit, offset)
create table test(id int, name varchar(255));
SELECT * FROM TEST a natural JOIN TEST b;
drop table test;
H2:
ID, NAME, ID, NAME
MySQL, PostgreSQL:
ID, NAME
Derby, HSQLDB, MS SQL Server:
no supported
H2 supports cancel for the embedded mode but not yet for the client / server mode. I will add a feature request for this.
set log 0;
create table test(id int primary key, name varchar);
@LOOP 10000 insert into test values(?, space(100000));
shutdown;
a SHUTDOWN command is not enough? >
No, currently SHUTDOWN will not correctly close the objects if LOG is set to 0.
It _should_ be enough however. I will change the code so that in the future SHUTDOWN will be enough, and regular database closing will be enough.
run benchmark with newest version of apache derby and hsqldb
java org.h2.tools.RunScript -url jdbc:h2:file:bug -user SA -script \temp\test\data.sql
data.sql, test.java
I have a big problem with PreparedStatements and version 2007-07-12 or above.
With 2007-04-29 or lower it works as expected.
In the test query i use a UNION ALL (i know, not well tested), but also with a simple SELECT/UPDATE/INSERT prepared statement with a where clause and parameters the problem happens, if i run the query more than once with different parameters. It always returns the result from the first executeQuery() or updates the rows return from the first query.
But the funny thing is, i cannot reproduce the bug with a simple SELECT in the test app.
If i run the test app, the output is with ver 2007-07-12:
Row Count 9
Sum 714.8621259
Row Count 9
Sum 714.8621259
Row Count 9
Sum 714.8621259
(looks like it takes every time the first result)
with ver 2007-04-29
Row Count 9
Sum 714.8621259
Row Count 7
Sum 0.0
Row Count 10
Sum 381.230477
drop table multi_pages;
drop table bib_holdings;
create table multi_pages(dir_num int, bh_id int);
insert into multi_pages values(1, 1);
insert into multi_pages values(2, 2);
insert into multi_pages values(3, 3);
create table bib_holdings(id int primary key, site varchar(255));
insert into bib_holdings values(1, 'WSTIAC');
insert into bib_holdings values(2, 'WSTIAC');
insert into bib_holdings values(3, 'WSTIAC');
select * from (select dir_num, count(*) as cnt
from multi_pages t, bib_holdings bh
where t.bh_id=bh.id and bh.site='WSTIAC' group by dir_num) as x
where cnt < 1000 order by dir_num asc;
explain select * from (select dir_num, count(*) as cnt
from multi_pages t, bib_holdings bh
where t.bh_id=bh.id and bh.site='WSTIAC' group by dir_num) as x
where cnt < 1000 order by dir_num asc;
select dir_num, count(*) as cnt
from multi_pages t, bib_holdings bh
where t.bh_id=bh.id and bh.site='WSTIAC'
group by dir_num
having count(*) < 1000
order by dir_num asc;
TestMultiThreadedKernel and integrate in unit tests; use also in-memory and so on
......@@ -624,6 +623,7 @@ TRUNC, NVL2, TO_CHAR, TO_DATE, TO_NUMBER;
// jdbc
new TestCancel().runTest(this);
// new TestDatabaseEventListener().runTest(this);
new TestDataSource().runTest(this);
new TestManyJdbcObjects().runTest(this);
new TestMetaData().runTest(this);
......
......@@ -63,11 +63,11 @@ public class TestTriggersConstraints extends TestBase implements Trigger {
rs = stat.executeQuery("SCRIPT");
checkRows(rs, new String[] {
"CREATE TRIGGER PUBLIC.INS_BEFORE BEFORE INSERT ON PUBLIC.TEST FOR EACH ROW NOWAIT CALL \""
+ getClass().getName() + "\"",
+ getClass().getName() + "\";",
"CREATE TRIGGER PUBLIC.INS_AFTER AFTER INSERT ON PUBLIC.TEST FOR EACH ROW NOWAIT CALL \""
+ getClass().getName() + "\"",
+ getClass().getName() + "\";",
"CREATE TRIGGER PUBLIC.UPD_BEFORE BEFORE UPDATE ON PUBLIC.TEST FOR EACH ROW NOWAIT CALL \""
+ getClass().getName() + "\"" });
+ getClass().getName() + "\";" });
while (rs.next()) {
String sql = rs.getString(1);
if (sql.startsWith("CREATE TRIGGER")) {
......
CREATE TABLE TEST(ID INT PRIMARY KEY, NAME VARCHAR(255));
INSERT INTO TEST VALUES(1, 'Hello'), (2, 'HelloWorld'), (3, 'HelloWorldWorld');
SELECT COUNT(*) FROM TEST WHERE NAME REGEXP 'World';
> 2;
SELECT NAME FROM TEST WHERE NAME REGEXP 'WorldW';
> HelloWorldWorld;
drop table test;
select * from (select x from (select x from dual)) where 1=x;
> 1;
CREATE VIEW TEST_VIEW AS SELECT X FROM (SELECT X FROM DUAL);
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论