/*
 * Copyright (C) 2007 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */


package tests.java.sql;

import SQLite.Database;
import SQLite.Function;
import SQLite.FunctionContext;
import dalvik.annotation.TestTargets;
import dalvik.annotation.TestLevel;
import dalvik.annotation.TestTargetNew;
import dalvik.annotation.TestTargetClass;

import junit.extensions.TestSetup;
import junit.framework.Test;
import junit.framework.TestCase;
import junit.framework.TestSuite;

import tests.support.Support_SQL;

import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;


/**
 * Functional test for the Statement.setQueryTimeout() method. Adopted from
 * Apache Derby project (Apache License 2.0).
 *
 * TODO Test requires transaction isolation to be supported. => Ticket 69
 *
 * This test consists of four parts: 1. Executes a SELECT
 * query in 4 different threads concurrently. The query calls a user-defined,
 * server-side function which delays the execution, so that it takes several
 * seconds even though the data volume is really low. The fetch operations take
 * longer time than the timeout value set. Hence, this part tests getting
 * timeouts from calls to ResultSet.next(). Two connections are used, two
 * threads execute their statement in the context of one connection, the other
 * two threads in the context of the other connection. Of the 4 threads, only
 * one executes its statement with a timeout value. This way, the test ensures
 * that the correct statement is affected by setQueryTimeout(), regardless of
 * what connection/transaction it and other statements are executed in the
 * context of.
 *
 * 2. Executes an INSERT query in multiple threads. This part tests
 * getting timeouts from calls to Statement.execute(). Each thread executes the
 * query in the context of a separate connection. There is no point in executing
 * multiple statements on the same connection; since only one statement per
 * connection executes at a time, there will be no interleaving of execution
 * between them (contrary to the first part of this test, where calls to
 * ResultSet.next() may be interleaved between the different threads). Half of
 * the threads execute their statement with a timeout value set, this is to
 * verify that the correct statements are affected by the timeout, while the
 * other statements execute to completion.
 * 3. Sets an invalid (negative)
 * timeout. Verifies that the correct exception is thrown.
 * 4. Tests that the query timeout value is not forgotten after the execution of a statement.
 */
@TestTargetClass(Statement.class)
public class QueryTimeoutTest extends TestCase {

    private static Statement statement;

    private static final int TIMEOUT = 1; // In seconds
    private static final int CONNECTIONS = 100;

    private static Connection[] connections = new Connection[CONNECTIONS];

    private static void printSQLException(SQLException e) {
        while (e != null) {
            e.printStackTrace();
            e = e.getNextException();
        }
    }

    /**
     * This Exception class is used for getting fail-fast behaviour in this
     * test. There is no point in wasting cycles running a test to the end when
     * we know that it has failed. In order to enable chaining of exceptions in
     * J2ME, this class defines its own "cause", duplicating existing
     * functionality in J2SE.
     */
    private static class TestFailedException extends Exception {
        private Throwable cause;

        public TestFailedException(Throwable t) {
            super();
            cause = t;
        }

        public TestFailedException(String message) {
            super(message);
            cause = null;
        }

        public TestFailedException(String message, Throwable t) {
            super(message);
            cause = t;
        }

        public String toString() {
            if (cause != null) {
                return super.toString() + ": " + cause.toString();
            } else {
                return super.toString();
            }
        }

        public void printStackTrace() {
            super.printStackTrace();
            if (cause != null) {
                if (cause instanceof SQLException) {
                    QueryTimeoutTest.printSQLException((SQLException) cause);
                } else {
                    cause.printStackTrace();
                }
            }
        }
    }

    /**
     * Used for executing the SQL statements for setting up this test (the
     * preparation phase). The queries testing setQueryTimeout() are run by the
     * StatementExecutor class.
     */
    private static void exec(Connection connection, String queryString,
            Collection ignoreExceptions) throws TestFailedException {
        Statement statement = null;
        try {
            statement = connection.createStatement();
            System.out.println(" Executing "+queryString);
            statement.execute(queryString);
        } catch (SQLException e) {
            String sqlState = e.getSQLState();
            if (!ignoreExceptions.contains(sqlState)) {
                throw new TestFailedException(e); // See finally block below
            }
        } finally {
            if (statement != null) {
                try {
                    statement.close();
                } catch (SQLException ee) {
                    // This will discard an exception possibly thrown above :-(
                    // But we don't worry too much about this, since:
                    // 1. This is just a test
                    // 2. We don't expect close() to throw
                    // 3. If it does, this will be inspected by a developer
                    throw new TestFailedException(ee);
                }
            }
        }
    }

    // Convenience method
    private static void exec(Connection connection, String queryString)
            throws TestFailedException {
        exec(connection, queryString, Collections.EMPTY_SET);
    }

    private static void dropTables(Connection conn, String tablePrefix)
            throws TestFailedException {
        Collection ignore = new HashSet();
        //ignore.add("42Y55");

        exec(conn, "drop table if exists " + tablePrefix + "_orig;", ignore);
        exec(conn, "drop table if exists " + tablePrefix + "_copy;", ignore);
    }

    private static void prepareTables(Connection conn, String tablePrefix)
            throws TestFailedException {
        System.out.println("Initializing tables with prefix " + tablePrefix);

        dropTables(conn, tablePrefix);

        exec(conn, "create table " + tablePrefix + "_orig (a int)");

        exec(conn, "create table " + tablePrefix + "_copy (a int)");

        for (int i = 0; i < 7; i++) {
        exec(conn, "insert into " + tablePrefix + "_orig"
                + " values ("+i+");");
        }
    }

    private static String getFetchQuery(String tablePrefix) {
        /**
         * The reason for using the mod function here is to force at least one
         * invocation of ResultSet.next() to read more than one row from the
         * table before returning. This is necessary since timeout is checked
         * only when reading rows from base tables, and when the first row is
         * read, the query still has not exceeded the timeout.
         */
        return "select a from " + tablePrefix
                + "_orig where mod(DELAY(1,a),3)=0";
    }

    private static String getExecQuery(String tablePrefix) {
        return "insert into " + tablePrefix + "_copy select a from "
                + tablePrefix + "_orig where DELAY(1,1)=1";
    }

    private static class StatementExecutor extends Thread {
        private PreparedStatement statement;
        private boolean doFetch;
        private int timeout;
        private SQLException sqlException;
        private String name;
        private long highestRunTime;

        public StatementExecutor(PreparedStatement statement, boolean doFetch,
                int timeout) {
            this.statement = statement;
            this.doFetch = doFetch;
            this.timeout = timeout;
            highestRunTime = 0;
            sqlException = null;
            if (timeout > 0) {
                try {
                    statement.setQueryTimeout(timeout);
                } catch (SQLException e) {
                    sqlException = e;
                }
            }
        }

        private void setHighestRunTime(long runTime) {
            synchronized (this) {
                highestRunTime = runTime;
            }
        }

        public long getHighestRunTime() {
            synchronized (this) {
                return highestRunTime;
            }
        }

        private boolean fetchRow(ResultSet resultSet) throws SQLException {
            long startTime = System.currentTimeMillis();
            boolean hasNext = resultSet.next();
            long endTime = System.currentTimeMillis();
            long runTime = endTime - startTime;
            if (runTime > highestRunTime) setHighestRunTime(runTime);
            return hasNext;
        }

        public void run() {
            if (sqlException != null) return;

            ResultSet resultSet = null;

            try {
                if (doFetch) {
                    long startTime = System.currentTimeMillis();
                    resultSet = statement.executeQuery();
                    long endTime = System.currentTimeMillis();
                    setHighestRunTime(endTime - startTime);
                    while (fetchRow(resultSet)) {
                        yield();
                    }
                } else {
                    long startTime = System.currentTimeMillis();
                    statement.execute();
                    long endTime = System.currentTimeMillis();
                    setHighestRunTime(endTime - startTime);
                }
            } catch (SQLException e) {
                synchronized (this) {
                    sqlException = e;
                }
            } finally {
                if (resultSet != null) {
                    try {
                        resultSet.close();
                    } catch (SQLException ex) {
                        if (sqlException != null) {
                            System.err.println("Discarding previous exception");
                            sqlException.printStackTrace();
                        }
                        sqlException = ex;
                    }
                }
            }
        }

        public SQLException getSQLException() {
            synchronized (this) {
                return sqlException;
            }
        }
    }

    /**
     * This method compares a thrown SQLException's SQLState value to an
     * expected SQLState. If they do not match, a TestFailedException is thrown
     * with the given message string.
     */
    private static void expectException(String expectSqlState,
            SQLException sqlException, String failMsg)
            throws TestFailedException {
        if (sqlException == null) {
            throw new TestFailedException(failMsg);
        } else {
            String sqlState = sqlException.getSQLState();
            if (!expectSqlState.equals(sqlState)) {
                throw new TestFailedException(sqlException);
            }
        }
    }

    // A convenience method which wraps a SQLException
    private static PreparedStatement prepare(Connection conn, String query)
            throws TestFailedException {
        try {
            return conn.prepareStatement(query);
        } catch (SQLException e) {
            throw new TestFailedException(e);
        }
    }

    /**
     * Part 1 of this test.
     */
    @TestTargetNew(
        level = TestLevel.PARTIAL_COMPLETE,
        notes = "Testing timeout with fetch operations",
        method = "setQueryTimeout",
        args = {int.class}
    )
    public static void testTimeoutWithFetch() throws TestFailedException {
        System.out.println("Testing timeout with fetch operations");

        Connection conn1 = connections[0];
        Connection conn2 = connections[1];

        try {
            conn1.setAutoCommit(false);
            conn2.setAutoCommit(false);
        } catch (SQLException e) {
            throw new TestFailedException("Unexpected Exception", e);
        }

        // The idea with these 4 statements is as follows:
        // A - should time out
        // B - different stmt on the same connection; should NOT time out
        // C - different stmt on different connection; should NOT time out
        // D - here just to create equal contention on conn1 and conn2

        PreparedStatement statementA = prepare(conn1, getFetchQuery("t"));
        PreparedStatement statementB = prepare(conn1, getFetchQuery("t"));
        PreparedStatement statementC = prepare(conn2, getFetchQuery("t"));
        PreparedStatement statementD = prepare(conn2, getFetchQuery("t"));

        StatementExecutor[] statementExecutor = new StatementExecutor[4];
        statementExecutor[0] = new StatementExecutor(statementA, true, TIMEOUT);
        statementExecutor[1] = new StatementExecutor(statementB, true, 0);
        statementExecutor[2] = new StatementExecutor(statementC, true, 0);
        statementExecutor[3] = new StatementExecutor(statementD, true, 0);

        for (int i = 3; i >= 0; --i) {
            statementExecutor[i].start();
        }

        for (int i = 0; i < 4; ++i) {
            try {
                statementExecutor[i].join();
            } catch (InterruptedException e) {
                throw new TestFailedException("Should never happen", e);
            }
        }

        /**
         * Actually, there is no guarantee that setting a query timeout for a
         * statement will actually cause a timeout, even if execution of the
         * statement takes longer than the specified timeout. However, these
         * queries execute significantly longer than the specified query
         * timeout. Also, the cancellation mechanism implemented should be quite
         * responsive. In sum, we expect the statement to always time out. If it
         * does not time out, however, we print the highest execution time for
         * the query, as an assistance in determining why it failed. Compare the
         * number to the TIMEOUT constant in this class (note that the TIMEOUT
         * constant is in seconds, while the execution time is in milliseconds).
         */
        expectException("XCL52", statementExecutor[0].getSQLException(),
                "fetch did not time out. Highest execution time: "
                        + statementExecutor[0].getHighestRunTime() + " ms");

        System.out.println("Statement 0 timed out");

        for (int i = 1; i < 4; ++i) {
            SQLException sqlException = statementExecutor[i].getSQLException();
            if (sqlException != null) {
                throw new TestFailedException("Unexpected exception in " + i,
                        sqlException);
            }
            System.out.println("Statement " + i + " completed");
        }

        try {
            statementA.close();
            statementB.close();
            statementC.close();
            statementD.close();
            conn1.commit();
            conn2.commit();
        } catch (SQLException e) {
            throw new TestFailedException(e);
        }
    }

    /**
     *
     * @test {@link java.sql.Statement#setQueryTimeout(int) }
     *
     * Part two of this test.
     */
    @TestTargetNew(
        level = TestLevel.PARTIAL_COMPLETE,
        notes = "test timeout with st.exec()",
        method = "setQueryTimeout",
        args = {int.class}
    )
    public static void testTimeoutWithExec()
            throws TestFailedException {
        System.out.println("Testing timeout with an execute operation");

        for (int i = 0; i < connections.length; ++i) {
            try {
                connections[i].setAutoCommit(true);
            } catch (SQLException e) {
                throw new TestFailedException("Unexpected Exception", e);
            }
        }

        PreparedStatement statements[] = new PreparedStatement[connections.length];
        for (int i = 0; i < statements.length; ++i) {
            statements[i] = prepare(connections[i], getExecQuery("t"));
        }

        StatementExecutor[] executors = new StatementExecutor[statements.length];
        for (int i = 0; i < executors.length; ++i) {
            int timeout = (i % 2 == 0) ? TIMEOUT : 0;
            executors[i] = new StatementExecutor(statements[i], false, timeout);
        }

        for (int i = 0; i < executors.length; ++i) {
            executors[i].start();
        }

        for (int i = 0; i < executors.length; ++i) {
            try {
                executors[i].join();
            } catch (InterruptedException e) {
                throw new TestFailedException("Should never happen", e);
            }
        }

        /**
         * Actually, there is no guarantee that setting a query timeout for a
         * statement will actually cause a timeout, even if execution of the
         * statement takes longer than the specified timeout. However, these
         * queries execute significantly longer than the specified query
         * timeout. Also, the cancellation mechanism implemented should be quite
         * responsive. In sum, we expect the statement to always time out. If it
         * does not time out, however, we print the highest execution time for
         * the query, as an assistance in determining why it failed. Compare the
         * number to the TIMEOUT constant in this class (note that the TIMEOUT
         * constant is in seconds, while the execution time is in milliseconds).
         */
        for (int i = 0; i < executors.length; ++i) {
            int timeout = (i % 2 == 0) ? TIMEOUT : 0;
            if (timeout > 0) {
                expectException("XCL52", executors[i].getSQLException(),
                        "exec did not time out. Execution time: "
                                + executors[i].getHighestRunTime() + " ms");
            } else {
                SQLException sqlException = executors[i].getSQLException();
                if (sqlException != null) {
                    throw new TestFailedException(sqlException);
                }
            }
        }

        System.out
                .println("Statements that should time out timed out, and statements that should complete completed");

        for (int i = 0; i < statements.length; ++i) {
            try {
                statements[i].close();
            } catch (SQLException e) {
                throw new TestFailedException(e);
            }
        }
    }


    /**
     *
     * @test {@link java.sql.Statement#setQueryTimeout(int) }
     *
     */
    @TestTargetNew(
        level = TestLevel.PARTIAL_COMPLETE,
        notes = "Testing setting a negative timeout value",
        method = "setQueryTimeout",
        args = {int.class}
    )
    public static void testInvalidTimeoutValue(Connection conn)
            throws TestFailedException {

        try {
            conn.setAutoCommit(true);
        } catch (SQLException e) {
            throw new TestFailedException("Unexpected Exception", e);
        }

        // Create statement
        PreparedStatement stmt = null;
        try {
            stmt = conn.prepareStatement("select * from sys.systables");
        } catch (SQLException e) {
            throw new TestFailedException("Unexpected Exception", e);
        }

        // Set (invalid) timeout value - expect exception
        try {
            stmt.setQueryTimeout(-1);
        } catch (SQLException e) {
            expectException("XJ074", e,
                    "negative timeout value should give exception");
        }

        System.out
                .println("Negative timeout value caused exception, as expected");

        // Execute the statement and fetch result
        ResultSet rs = null;
        try {
            rs = stmt.executeQuery();
            System.out.println("Execute returned a ResultSet");
            rs.close();
        } catch (SQLException e) {
            throw new TestFailedException("Unexpected Exception", e);
        } finally {
            try {
                stmt.close();
            } catch (SQLException e) {
                // This will discard an exception possibly thrown above :-(
                // But we don't worry too much about this, since:
                // 1. This is just a test
                // 2. We don't expect close() to throw
                // 3. If it does, this will be inspected by a developer
                throw new TestFailedException("close should not throw", e);
            }
        }
    }

    /**
     *
     * @test {@link java.sql.Statement#setQueryTimeout(int) }
     *
     * Part two of this test.
     */
    @TestTargetNew(
        level = TestLevel.PARTIAL_COMPLETE,
        notes = "timeout with executeUpdate call",
        method = "setQueryTimeout",
        args = {int.class}
    )
    public static void testTimeoutWithExecuteUpdate()
            throws TestFailedException {
        System.out.println("Testing timeout with executeUpdate call.");
        try {
            Statement stmt = connections[0].createStatement();
            stmt.setQueryTimeout(TIMEOUT);
            stmt.executeUpdate(getExecQuery("t"));
        } catch (SQLException sqle) {
            expectException("XCL52", sqle, "Should have timed out.");
        }
    }

    /** Test for DERBY-1692. */
    @TestTargetNew(
            level = TestLevel.PARTIAL_COMPLETE,
            notes = "Testing that Statement considers timeout.",
            method = "setQueryTimeout",
            args = {int.class}
        )
    public static void testRememberTimeoutValue()
            throws TestFailedException {
        String sql = getFetchQuery("t");
        try {
            Statement stmt = connections[0].createStatement();
            statementRemembersTimeout(stmt);
            PreparedStatement ps = connections[0].prepareStatement(sql);
            statementRemembersTimeout(ps);
            CallableStatement cs = connections[0].prepareCall(sql);
            statementRemembersTimeout(cs);
        } catch (SQLException sqle) {
            throw new TestFailedException("Unexpected Exception", sqle);
        }
    }

    public static void statementRemembersTimeout(Statement stmt)
            throws SQLException, TestFailedException {
        System.out.println("Testing that Statement remembers timeout.");
        stmt.setQueryTimeout(1);
        for (int i = 0; i < 3; i++) {
            try {
                ResultSet rs = stmt.executeQuery(getFetchQuery("t"));
                while (rs.next()) {
                    // do nothing
                }
                throw new TestFailedException("Should have timed out.");
            } catch (SQLException sqle) {
                expectException("XCL52", sqle, "Should have timed out.");
            }
        }
        stmt.close();
    }

    private static void statementRemembersTimeout(PreparedStatement ps)
            throws SQLException, TestFailedException {
        String name = (ps instanceof CallableStatement) ? "CallableStatement"
                : "PreparedStatement";
        System.out.println("Testing that " + name + " remembers timeout.");
        ps.setQueryTimeout(1);
        for (int i = 0; i < 3; i++) {
            try {
                ResultSet rs = ps.executeQuery();
                while (rs.next()) {
                    // do nothing
                }
                throw new TestFailedException("Should have timed out.");
            } catch (SQLException sqle) {
                expectException("XCL52", sqle, "Should have timed out.");
            }
        }
        ps.close();
    }

    /**
     * A function
     * arg0 : int seconds
     *
     */
    static class Delay implements SQLite.Function {

        public void function(FunctionContext fc, String[] args) {
            int seconds = new Integer(args[0]).intValue();
            int value = new Integer(args[1]).intValue();
            try {
                Thread.sleep(seconds * 1000);
            } catch (InterruptedException e) {
                // Ignore
            }
            fc.set_result(value);

        }

        public void last_step(FunctionContext fc) {
            // TODO Auto-generated method stub

        }

        public void step(FunctionContext fc, String[] args) {
            // TODO Auto-generated method stub

        }

    }

    /**
     * The actual main bulk of this test. Sets up the environment, prepares
     * tables, runs the tests, and shuts down.
     */
    public static Test suite() {

        TestSetup setup = new TestSetup( new TestSuite (QueryTimeoutTest.class)) {
            public void setUp() {

                // Establish connections
                Support_SQL.loadDriver();
                try {

                    for (int i = 0; i < connections.length; ++i) {
                        connections[i] = Support_SQL.getConnection();
                    }

                    for (int i = 0; i < connections.length; ++i) {
                        connections[i]
                                .setTransactionIsolation(Connection.TRANSACTION_READ_UNCOMMITTED);
                    }

                    // setup Delay function
                    prepare();

                } catch (Throwable e) {
                    fail("Unexpected SQLException " + e.toString());
                }

                System.out.println("Connections set up");

            }

            public void tearDown() {
                for (int i = connections.length - 1; i >= 0; --i) {
                    if (connections[i] != null) {
                        try {
                            connections[i].close();
                        } catch (SQLException ex) {
                            printSQLException(ex);
                        }
                    }
                }
                System.out.println("Closed connections");
            }

            public void prepare() throws TestFailedException {
                System.out.println("Preparing for testing queries with timeout");
                Database db = new Database();

                Connection conn = connections[0];


                try {
                    db.open(Support_SQL.getFilename(), 1);
                    conn.setAutoCommit(true);
                } catch (Exception e) {
                    throw new TestFailedException("Unexpected Exception", e);
                }

                Function delayFc = new Delay();
                db.create_function("DELAY", 2, delayFc);

                prepareTables(conn, "t");
            }
        };

        TestSuite ts = new TestSuite();
        ts.addTestSuite(QueryTimeoutTest.class);

        return setup;
    }

}
