/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.apache.openjpa.persistence.criteria;

import java.util.List;

import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Root;

import org.apache.openjpa.persistence.OpenJPAEntityManager;
import org.apache.openjpa.persistence.query.DomainObject;
import org.apache.openjpa.persistence.query.Expression;
import org.apache.openjpa.persistence.query.OpenJPAQueryBuilder;
import org.apache.openjpa.persistence.query.Predicate;
import org.apache.openjpa.persistence.query.QueryBuilderImpl;
import org.apache.openjpa.persistence.query.QueryDefinition;
import org.apache.openjpa.persistence.query.SelectItem;
import org.apache.openjpa.persistence.test.SingleEMFTestCase;


/**
 * Tests QueryDefinition via a set of example use cases from Criteria API
 * Section of Java Persistence API Version 2.0 [1].
 *
 * For each use case, a corresponding JPQL String is specified. The dynamically
 * constructed QueryDefinition and JPQL are both executed and their results
 * are compared for verification. As some of the use cases employ few
 * yet unimplemented JPQL 2.0 constructs such as KEY() or INDEX() or CASE,
 * when such queries fail to execute, the JPQL String is literally compared
 * to the stringified QueryDefinition.
 *
 * [1] <A href="http://jcp.org/aboutJava/communityprocess/pr/jsr317/index.html">
 * JPA API Specification Version 2.0</A>
 *
 *
 */
public class TestCriteria extends SingleEMFTestCase {
    protected OpenJPAQueryBuilder qb;
    protected StringComparison comparator = new StringComparison();

    @Override
    public void setUp() {
            super.setUp(CLEAR_TABLES,
                    "openjpa.DynamicEnhancementAgent", "false",
                    "openjpa.DataCache","true",
                    "openjpa.QueryCache","true",
                    Account.class,
                    Address.class,
                    A.class,
                    B.class,
                    CompUser.class,
                    Contact.class,
                    Contractor.class,
                    Course.class,
                    CreditCard.class,
                    Customer.class,
                    C.class,
                    Department.class,
                    DependentId.class,
                    Dependent.class,
                    D.class,
                    Employee.class,
                    Exempt.class,
                    FemaleUser.class,
                    FrequentFlierPlan.class,
                    Item.class,
                    LineItem.class,
                    Magazine.class,
                    MaleUser.class,
                    Manager.class,
                    Movie.class,
                    Order.class,
                    Person.class,
                    Phone.class,
                    Photo.class,
                    Product.class,
                    Publisher.class,
                    Semester.class,
                    Student.class,
                    TransactionHistory.class,
                    Transaction.class,
                    VideoStore.class);
        qb = (QueryBuilderImpl)emf.getDynamicQueryBuilder();
        emf.createEntityManager();
    }

    public void testLogicalPredicateAssociativity() {
        DomainObject e = qb.createQueryDefinition(Employee.class);
        Predicate p1 = e.get("salary").greaterThan(100);
        Predicate p2 = e.get("rating").equal(5);
        Predicate p3 = e.get("name").like("John");
        Predicate w1 = p1.and(p2.or(p3));
        Predicate w2 = (p1.and(p2)).or(p3);
        QueryDefinition q1 = e.select(e).where(w1);
        String jpql1 = qb.toJPQL(q1);
        emf.createEntityManager().createDynamicQuery(q1).getResultList();

        QueryDefinition q2 = e.select(e).where(w2);
        String jpql2 = qb.toJPQL(q2);
        System.err.println(jpql1);
        System.err.println(jpql2);
        assertNotEquals(jpql1, jpql2);
        emf.createEntityManager().createDynamicQuery(q2).getResultList();
    }

    public void testMultipleDomainOfSameClass() {
        DomainObject o1 = qb.createQueryDefinition(Order.class);
        DomainObject o2 = o1.addRoot(Order.class);
        o1.select(o1)
          .where(o1.get("quantity").greaterThan(o2.get("quantity"))
            .and(o2.get("customer").get("lastName").equal("Smith"))
            .and(o2.get("customer").get("firstName").equal("John")));

        String jpql = "select o from Order o, Order o2" +
                      " where o.quantity > o2.quantity" +
                      " and o2.customer.lastName = 'Smith'" +
                      " and o2.customer.firstName = 'John'";
        compare(jpql, o1);
    }

    public void testFetchJoin() {
        DomainObject d = qb.createQueryDefinition(Department.class);
        d.leftJoinFetch("employees");
        d.where(d.get("deptNo").equal(1));


        String jpql = "select d from Department d" +
                      " LEFT JOIN FETCH d.employees" +
                      " where d.deptNo = 1";
        compare(jpql, d);
    }

    public void testMultipartNavigation() {
        DomainObject e = qb.createQueryDefinition(Employee.class);
        DomainObject p = e.join("contactInfo").join("phones");
        e.where(e.get("contactInfo").get("address").get("zipCode")
                .equal("95094")).select(p.get("vendor"));


        String jpql = "select p.vendor from Employee e" +
                      " JOIN e.contactInfo c JOIN c.phones p" +
                      " where e.contactInfo.address.zipCode = '95094'";
        compare(jpql, e);
    }

    public void testOperatorPath() {
        QueryDefinition qdef = qb.createQueryDefinition();
        DomainObject item = qdef.addRoot(Item.class);
        DomainObject photo = item.join("photos");
        qdef.select(item.get("name"), photo.value())
            .where(photo.key().like("egret"));


        String jpql = "select i.name, VALUE(p)"
                    + " from Item i join i.photos p"
                    + " where KEY(p) like 'egret'";
        compare(jpql, qdef);
    }

    public void testLiteral() {
        DomainObject c = qb.createQueryDefinition(Customer.class);
        DomainObject o = c.join("orders");
        DomainObject a = c.join("address");
        o.where(a.get("state").equal("CA").and(
            a.get("county").equal("Santa Clara")));
        o
            .select(o.get("quantity"), o.get("cost").times(1.08), a
                .get("zipCode"));

        String jpql = "select o.quantity, o.cost*1.08, a.zipCode" +
                      " from Customer c join c.orders o join c.address a" +
                      " where a.state = 'CA' and a.county = 'Santa Clara'";
        compare(jpql, c);
    }

    public void testTypeExpression() {
        DomainObject e = qb.createQueryDefinition(Employee.class);
        e.select(e.type())
         .where(e.type().equal(Exempt.class).not());

        String jpql = "select TYPE(e)" +
                      " from Employee e" +
                      " where TYPE(e) <> Exempt";
        compare(jpql, e);
    }

    public void testIndex() {
        DomainObject c = qb.createQueryDefinition(Course.class);
        DomainObject w = c.join("studentWaitList");
        c.where(c.get("name").equal("Calculus").and(w.index().equal(0)))
         .select(w.get("name"));

        String jpql = "select s.name" +
                      " from Course c join c.studentWaitList s" +
                      " where c.name = 'Calculus' and INDEX(s) = 0";
        compare(jpql, c);
    }

    public void testSum() {
        DomainObject o = qb.createQueryDefinition(Order.class);
        DomainObject l = o.join("lineItems");
        DomainObject c = o.join("customer");
        c.where(c.get("lastName").equal("Smith").and(c.get("firstName").
            equal("John"))).select(l.get("price").sum());

        String jpql = "select SUM(l.price)" +
                      " from Order o join o.lineItems l JOIN o.customer c" +
                      " where c.lastName = 'Smith' and c.firstName = 'John'";
        compare(jpql, c);
    }

    public void testSize() {
        DomainObject d = qb.createQueryDefinition(Department.class);
        d.where(d.get("name").equal("Sales"))
         .select(d.get("employees").size());

        String jpql = "select SIZE(d.employees)" +
                      " from Department d " +
                      " where d.name = 'Sales'";
        compare(jpql, d);
    }

    public void testCount() {

        EntityManager em = emf.createEntityManager();
        em.getTransaction().begin();
        for(int i = 0;i<50;i++)
            em.persist(new Department());
        em.getTransaction().commit();

        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Department> q = cb.createQuery(Department.class);
        Root<Department> book = q.from(Department.class);
        TypedQuery<Department> dept = em.createQuery(q);
        int size = dept.getResultList().size();

        CriteriaQuery<Long> c = cb.createQuery(Long.class);
        Root<?> from = c.from(Department.class);
        c.select(cb.count(from));
        TypedQuery<Long> query = em.createQuery(c);
        long count = query.getSingleResult();
        assertEquals(size, count);
    }

    public void testGeneralCase() {
        DomainObject e = qb.createQueryDefinition(Employee.class);
        e.where(e.get("department").get("name").equal("Engineering"));
        e.select(e.get("name"),
        e.generalCase()
        .when(e.get("rating").equal(1))
        .then(e.get("salary").times(1.1))
        .when(e.get("rating").equal(2))
        .then(e.get("salary").times(1.2))
        .elseCase(e.get("salary").times(1.01)));

        String jpql = "SELECT e.name,"
                    + " CASE WHEN e.rating = 1 THEN e.salary * 1.1"
                    + " WHEN e.rating = 2 THEN e.salary * 1.2"
                    + " ELSE e.salary * 1.01"
                    + " END"
                    + " FROM Employee e"
                    + " WHERE e.department.name = 'Engineering'";

        compare(jpql, e);
    }

    public void testMemberOf() {
        DomainObject p = qb.createQueryDefinition(Person.class);
        p.where(p.literal("Joe").member(p.get("nicknames")));

        String jpql = "select p from Person p " +
                      " where 'Joe' MEMBER OF p.nicknames";
        compare(jpql, p);
    }

    public void testParamater() {
        QueryDefinition qdef = qb.createQueryDefinition();
        DomainObject customer = qdef.addRoot(Customer.class);
        qdef.where(customer.get("status").equal(qdef.param("status")));

        String jpql = "select c from Customer c " +
                      " where c.status = :status";
        compare(jpql, qdef, "status", 1);
    }

    public void testBetween() {
        DomainObject c = qb.createQueryDefinition(CreditCard.class);
        DomainObject t = c.join("transactionHistory");
        c.select(t).where(c.get("holder").get("name").equal("John Doe")
                .and(t.index().between(0, 9)));


        String jpql = "select t from CreditCard c JOIN c.transactionHistory t" +
                      " where c.holder.name = 'John Doe' AND INDEX(t) " +
                      " BETWEEN 0 AND 9";

        compare(jpql, c);
    }

    public void testIsEmpty() {
        DomainObject o = qb.createQueryDefinition(Order.class);
        o.where(o.get("lineItems").isEmpty());


        String jpql = "select o from Order o " +
                      " where o.lineItems IS EMPTY";
        compare(jpql, o);
    }

    public void testNonCorrelatedSubQuery() {
        QueryDefinition q1 = qb.createQueryDefinition();
        DomainObject goodCustomer = q1.addRoot(Customer.class);

        QueryDefinition q2 = qb.createQueryDefinition();
        DomainObject customer = q2.addRoot(Customer.class);

        q1.where(goodCustomer.get("balanceOwned")
                .lessThan(q2.select(customer.get("balanceOwned").avg())));

        String jpql = "select c from Customer c "
                    + " where c.balanceOwned < "
                    + "(select AVG(c2.balanceOwned) from Customer c2)";
        compare(jpql, q1);
    }

    public void testNew() {
        QueryDefinition q = qb.createQueryDefinition();
        DomainObject customer = q.addRoot(Customer.class);
        DomainObject order = customer.join("orders");
        q.where(order.get("count").greaterThan(100))
         .select(q.newInstance(Customer.class, customer.get("id"),
                                               customer.get("status"),
                                               order.get("count")));


        String jpql =
            "SELECT NEW org.apache.openjpa.persistence.criteria.Customer"
                + "(c.id, c.status, o.count)"
                + " FROM Customer c JOIN c.orders o" + " WHERE o.count > 100";
        compare(jpql, q);
    }

    public void testKeyValueOperatorPath() {
        QueryDefinition q = qb.createQueryDefinition();
        DomainObject v = q.addRoot(VideoStore.class);
        DomainObject i = v.join("videoInventory");
        q.where(v.get("location").get("zipCode").equal("94301").and(
            i.value().greaterThan(0)));
        q.select(v.get("location").get("street"), i.key().get("title"), i
            .value());

        String jpql = "SELECT v.location.street, KEY(v2).title, VALUE(v2)"
                    + " FROM VideoStore v JOIN v.videoInventory v2"
                    + " WHERE v.location.zipCode = '94301' AND VALUE(v2) > 0";

        compare(jpql, q);
    }

    public void testGroupByHaving() {
        QueryDefinition q = qb.createQueryDefinition();
        DomainObject customer = q.addRoot(Customer.class);
        q.select(customer.get("status"), customer.get("filledOrderCount").avg(),
                 customer.count())
         .groupBy(customer.get("status"))
         .having(customer.get("status").in(1, 2));

        String jpql = "SELECT c.status, AVG(c.filledOrderCount), COUNT(c)"
                    + " FROM Customer c"
                    + " GROUP BY c.status"
                    + " HAVING c.status IN (1, 2)";

        compare(jpql, q);
    }

    public void testGroupByHaving2() {
        QueryDefinition q = qb.createQueryDefinition();
        DomainObject customer = q.addRoot(Customer.class);
        q.select(customer.get("country"), customer.count())
         .groupBy(customer.get("country"))
         .having(customer.count().greaterThan(30));

        String jpql = "SELECT c.country, COUNT(c)"
                    + " FROM Customer c"
                    + " GROUP BY c.country"
                    + " HAVING COUNT(c) > 30";
        compare(jpql, q);
    }

    public void testOrderBy() {
        QueryDefinition q = qb.createQueryDefinition();
        DomainObject customer = q.addRoot(Customer.class);
        DomainObject order = customer.join("orders");
        DomainObject address = customer.join("address");
        q.where(address.get("state").equal("CA"))
        .select(order)
        .orderBy(order.get("quantity").desc(), order.get("totalcost"));
        String jpql = "SELECT o"
                    + " FROM Customer c JOIN c.orders o JOIN c.address a"
                    + " WHERE a.state = 'CA'"
                    + " ORDER BY o.quantity DESC, o.totalcost";
        compare(jpql, q);
    }

    public void testOrderBy2() {
        QueryDefinition q = qb.createQueryDefinition();
        DomainObject customer = q.addRoot(Customer.class);
        DomainObject order = customer.join("orders");
        DomainObject address = customer.join("address");
        q.where(address.get("state").equal("CA"))
        .select(order.get("quantity"), address.get("zipCode"))
        .orderBy(order.get("quantity").desc(), address.get("zipCode"));
        String jpql = "SELECT o.quantity, a.zipCode"
                    + " FROM Customer c JOIN c.orders o JOIN c.address a"
                    + " WHERE a.state = 'CA'"
                    + " ORDER BY o.quantity DESC, a.zipCode";
        compare(jpql, q);
    }

    public void testOrderByExpression() {
        DomainObject o = qb.createQueryDefinition(Order.class);
        DomainObject a = o.join("customer").join("address");
        SelectItem taxedCost = o.get("cost").times(1.08);
        o.select(o.get("quantity"), taxedCost, a.get("zipCode"))
        .where(a.get("state").equal("CA")
        .and(a.get("county").equal("Santa Clara")))
        .orderBy(o.get("quantity"), taxedCost, a.get("zipCode"));

        String jpql = "SELECT o.quantity, o.cost*1.08 as o2, a.zipCode"
                    + " FROM Order o JOIN o.customer c JOIN c.address a"
                    + " WHERE a.state = 'CA' AND a.county = 'Santa Clara'"
                    + " ORDER BY o.quantity, o2, a.zipCode";

        compare(jpql, o);
    }

    public void testCorrelatedSubquery() {
        QueryDefinition q1 = qb.createQueryDefinition();
        DomainObject emp = q1.addRoot(Employee.class);

        QueryDefinition q2 = qb.createQueryDefinition();
        DomainObject spouseEmp = q2.addRoot(Employee.class);

        q2.where(spouseEmp.equal(emp.get("spouse"))).select(spouseEmp);
        q1.selectDistinct(emp).where(q2.exists());

        String jpql = "SELECT DISTINCT e "
                    + " FROM Employee e"
                    + " WHERE EXISTS ("
                            + " SELECT e2 "
                            + " FROM Employee e2"
                            + " WHERE e2 = e.spouse)";

        compare(jpql, q1);
    }

    public void testCreateSubquery() {
        DomainObject customer = qb.createQueryDefinition(Customer.class);
        DomainObject order =
            qb.createSubqueryDefinition(customer.get("orders"));
        customer.where(order.select(order.get("cost").avg()).greaterThan(100));

        String jpql = "SELECT c "
                    + " FROM Customer c"
                    + " WHERE (SELECT AVG(o.cost) FROM c.orders o) > 100";

        compare(jpql, customer);
    }

    public void testTypeList() {
        DomainObject q = qb.createQueryDefinition(Employee.class);
        q.where(q.type().in(Exempt.class, Contractor.class));

        String jpql = "SELECT e "
            + " FROM Employee e"
            + " WHERE TYPE(e) IN (Exempt, Contractor)";

        compare(jpql, q);
    }

    public void testStringList() {
        DomainObject q = qb.createQueryDefinition(Customer.class);
        q.where(q.get("country").in("USA", "UK", "France"));

        String jpql = "SELECT c "
            + " FROM Customer c"
            + " WHERE c.country IN ('USA', 'UK', 'France')";
        compare(jpql, q);
    }

    public void testConcat() {
        DomainObject e = qb.createQueryDefinition(Employee.class);
        DomainObject f = e.join("frequentFlierPlan");
        Expression c =
        e.generalCase().when(f.get("annualMiles").greaterThan(50000)).then(
                "Platinum").when(f.get("annualMiles").greaterThan(25000)).then(
                "Gold").elseCase("XYZ");
        e.select(e.get("name"), f.get("name"), e.concat(c, e
            .literal("Frequent Flyer")));

        String jpql = "SELECT e.name, f.name, CONCAT("
            + " CASE WHEN f.annualMiles > 50000 THEN 'Platinum'"
            + " WHEN f.annualMiles > 25000 THEN 'Gold'"
            + " ELSE 'XYZ' END, 'Frequent Flyer')"
            + " FROM Employee e JOIN e.frequentFlierPlan f";

        compare(jpql, e);
    }

    public void testCorrelatedSubquerySpecialCase1() {
        DomainObject o = qb.createQueryDefinition(Order.class);
        DomainObject a = qb.createSubqueryDefinition(o.get("customer").
            get("accounts"));
        o.select(o)
         .where(o.literal(10000).lessThan(a.select(a.get("balance")).all()));

        String jpql =
            "select o from Order o" + " where 10000 < ALL "
                + " (select a.balance from o.customer c "
                + "join o.customer.accounts a)";

        compare(jpql, o);
    }

    public void testCorrelatedSubquerySpecialCase2() {
        DomainObject o = qb.createQueryDefinition(Order.class);
        DomainObject c = o.join("customer");
        DomainObject a = qb.createSubqueryDefinition(c.get("accounts"));
        o.select(o)
         .where(o.literal(10000).lessThan(a.select(a.get("balance")).all()));

        String jpql = "select o from Order o JOIN o.customer c"
                    + " where 10000 < ALL "
                    + " (select a.balance from c.accounts a)";

        compare(jpql, o);
    }

    public void testRecursiveDefinitionIsNotAllowed() {
        DomainObject q = qb.createQueryDefinition(Customer.class);
        q.where(q.exists().and(q.get("name").equal("wrong")));

        try {
            qb.toJPQL(q);
            fail();
        } catch (RuntimeException e) {
            // good
        }
    }

    // ---------------------------------------------------------------------
    // verification methods
    // ---------------------------------------------------------------------

    /**
     * Compare by executing the queries generated from the given JPQL and
     * QueryDefinition.
     */
    void compare(String jpql, QueryDefinition q) {
        compare(jpql, q, (Object[])null);
    }

    /**
     * Compare hand crafted JPQL and QueryDefinition.
     * If skip is null then execute both queries against the database, otherwise
     * compare them literally.
     */
    void compare(String jpql, QueryDefinition q, Object...p) {
        executeActually(jpql, q, p);
    }

    /**
     * Compare the string version of QueryDefinition and given JPQL string with
     * some flexibility of case-insensitive reserved words.
     */
    private void compareLiterally(String jpql, QueryDefinition q) {
        String actual = qb.toJPQL(q);
        if (!comparator.compare(jpql,actual))
            fail("\r\nExpected: [" + jpql + "]\r\nActual  : [" + actual + "]");
    }

    /**
     * Executes the given JPQL and QueryDefinition independently and compare
     * their results.
     */
    private void executeActually(String jpql, QueryDefinition q, Object...p) {
        OpenJPAEntityManager em = emf.createEntityManager();
        List<?> criteriaResult = null;
        List<?> jpqlResult = null;
        Throwable criteriaError = null;
        Throwable jpqlError = null;

        try {
            Query cq = em.createDynamicQuery(q);
            setParameters(cq, p);
            criteriaResult = cq.getResultList();
        } catch (Exception e) {
            criteriaError = e;
        }
        try {
            Query nq = em.createQuery(jpql);
            setParameters(nq, p);
            jpqlResult = nq.getResultList();
        } catch (Exception e) {
            jpqlError = e;
        }

        if (criteriaError == null && jpqlError == null) {
            assertEquals(criteriaResult.size(), jpqlResult.size());
        } else if (criteriaError != null && jpqlError == null) {
            fail("QueryDefinition generated invalid JPQL\r\n"
                + "Criteria [" + qb.toJPQL(q) + "]\r\n"
                + "error : " + criteriaError.getMessage());
        } else if (criteriaError == null && jpqlError != null) {
            fail("Handcrafted JPQL is invalid \r\n"
                    + "JPQL [" + jpql + "]\r\n"
                    + "error : " + jpqlError.getMessage());
        } else {
            compareLiterally(jpql, q);
        }
    }
        void setParameters(Query q, Object...p) {
        if (p == null)
            return;
        for (int i = 0; i < p.length; i += 2) {
            q.setParameter(p[i].toString(), p[i+1]);
        }
    }
}

