diff --git a/src/adql/parser/grammar/adqlGrammar201.jj b/src/adql/parser/grammar/adqlGrammar201.jj index 8ed7059cf7ca2f10310a77e2cb050f8bd0f130f3..5eb28a82c09309a5fd24ec914e05387639215d7c 100644 --- a/src/adql/parser/grammar/adqlGrammar201.jj +++ b/src/adql/parser/grammar/adqlGrammar201.jj @@ -301,6 +301,7 @@ TOKEN : { | < ORDER: "ORDER" > { matchedToken.adqlReserved = true; } | < ASC: "ASC" > { matchedToken.adqlReserved = true; } | < DESC: "DESC" > { matchedToken.adqlReserved = true; } +| < OFFSET: "OFFSET" > { matchedToken.adqlReserved = true; } } /* ************* */ @@ -439,6 +440,7 @@ ADQLQuery QueryExpression(): {TextPosition endPos = null;} { [GroupBy() {endPos = query.getGroupBy().getPosition();}] [Having() {endPos = query.getHaving().getPosition();}] [OrderBy() {endPos = query.getOrderBy().getPosition();}] + [Offset() {endPos = new TextPosition(token);}] { // set the position of the query: query.setPosition(new TextPosition(query.getSelect().getPosition(), endPos)); @@ -577,6 +579,17 @@ void OrderBy(): {ClauseADQL<ADQLOrder> orderBy = query.getOrderBy(); ADQLOrder o { orderBy.setPosition(new TextPosition(start, token)); } } +void Offset(): { Token t; } { + <OFFSET> t=<UNSIGNED_INTEGER> + { + try{ + query.setOffset(Integer.parseInt(t.image)); + }catch(NumberFormatException nfe){ + throw new ParseException("The OFFSET limit (\""+t.image+"\") isn't a regular unsigned integer!", new TextPosition(t)); + } + } +} + /* *************************** */ /* COLUMN AND TABLE REFERENCES */ /* *************************** */ diff --git a/src/adql/query/ADQLQuery.java b/src/adql/query/ADQLQuery.java index eb230f8ccfbc495a49cd46824c12389c116d7afd..6d1b75423ad30c00ada4c822217f330064b672d5 100644 --- a/src/adql/query/ADQLQuery.java +++ b/src/adql/query/ADQLQuery.java @@ -52,7 +52,7 @@ import adql.search.ISearchHandler; * </p> * * @author Grégory Mantelet (CDS;ARI) - * @version 2.0 (07/2019) + * @version 2.0 (08/2019) */ public class ADQLQuery implements ADQLObject { @@ -82,6 +82,10 @@ public class ADQLQuery implements ADQLObject { /** The ADQL clause ORDER BY. */ private ClauseADQL<ADQLOrder> orderBy; + /** The ADQL clause OFFSET. + * @since 2.0 */ + private int offset; + /** Position of this Query (or sub-query) inside the whole given ADQL query * string. * @since 1.4 */ @@ -112,6 +116,7 @@ public class ADQLQuery implements ADQLObject { groupBy = new ClauseADQL<ADQLColumn>("GROUP BY"); having = new ClauseConstraints("HAVING"); orderBy = new ClauseADQL<ADQLOrder>("ORDER BY"); + offset = -1; } /** @@ -130,6 +135,7 @@ public class ADQLQuery implements ADQLObject { groupBy = (ClauseADQL<ADQLColumn>)toCopy.groupBy.getCopy(); having = (ClauseConstraints)toCopy.having.getCopy(); orderBy = (ClauseADQL<ADQLOrder>)toCopy.orderBy.getCopy(); + offset = toCopy.offset; position = (toCopy.position == null) ? null : new TextPosition(toCopy.position); } @@ -160,6 +166,7 @@ public class ADQLQuery implements ADQLObject { groupBy.clear(); having.clear(); orderBy.clear(); + offset = -1; position = null; } @@ -331,6 +338,62 @@ public class ADQLQuery implements ADQLObject { position = null; } + /** + * Gets the OFFSET value of this query. + * + * @return Its OFFSET value, + * or a negative value if no OFFSET is set. + * + * @since 2.0 + */ + public final int getOffset() { + return offset; + } + + /** + * Tell whether an OFFSET is set in this query. + * + * @return <code>true</code> if an OFFSET is set, + * <code>false</code> otherwise. + * + * @since 2.0 + */ + public final boolean hasOffset() { + return (offset > -1); + } + + /** + * Remove the OFFSET value of this query. + * + * <p><i><b>Note:</b> + * The position of the query is erased. + * </i></p>. + * + * @since 2.0 + */ + public void setNoOffset() { + offset = -1; + position = null; + } + + /** + * Replaces its OFFSET value by the given one. + * + * <p><i><b>Note:</b> + * The position of the query is erased. + * </i></p> + * + * @param newOffset The new OFFSET value. + * <i><b>Note:</b> a negative value removes the OFFSET from + * this query.</i> + * + * @since 2.0 + */ + public void setOffset(final int newOffset) { + offset = newOffset; + position = null; + } + @Override public final TextPosition getPosition() { return position; @@ -580,6 +643,9 @@ public class ADQLQuery implements ADQLObject { if (!orderBy.isEmpty()) adql.append('\n').append(orderBy.toADQL()); + if (hasOffset()) + adql.append("\nOFFSET ").append(offset); + return adql.toString(); } diff --git a/src/adql/translator/JDBCTranslator.java b/src/adql/translator/JDBCTranslator.java index 7cf11859431b251d9ef332a885f1bd33d3aa6a79..f31476be4fedf2dd1f0df6f61a453cf0bfea7cc9 100644 --- a/src/adql/translator/JDBCTranslator.java +++ b/src/adql/translator/JDBCTranslator.java @@ -375,7 +375,10 @@ public abstract class JDBCTranslator implements ADQLTranslator { sql.append('\n').append(translate(query.getOrderBy())); if (query.getSelect().hasLimit()) - sql.append("\nLimit ").append(query.getSelect().getLimit()); + sql.append("\nLIMIT ").append(query.getSelect().getLimit()); + + if (query.hasOffset()) + sql.append("\nOFFSET ").append(query.getOffset()); return sql.toString(); } diff --git a/src/adql/translator/SQLServerTranslator.java b/src/adql/translator/SQLServerTranslator.java index 3a045b0d41e96fc7caeb25773128dbf00f01fbac..21c6b9bbf1cc9960c65621637d289ffc7ef7fe86 100644 --- a/src/adql/translator/SQLServerTranslator.java +++ b/src/adql/translator/SQLServerTranslator.java @@ -134,10 +134,13 @@ public class SQLServerTranslator extends JDBCTranslator { /** * For SQL Server, {@link #translate(ClauseSelect)} must be overridden for - * TOP/LIMIT handling. We must not add the LIMIT at the end of the query, it - * must go in the SELECT. + * LIMIT and OFFSET handling. * - * @see #translate(ClauseSelect) + * <p><i><b>Implementation note:</b> + * LIMIT is replaced by FETCH NEXT instead of TOP because of the addition + * of OFFSET support in ADQL-2.1 grammar. With MS-SQLServer, TOP can not be + * used with OFFSET...it must be OFFSET...LIMIT.... + * </i></p> */ @Override public String translate(ADQLQuery query) throws TranslationException { @@ -157,6 +160,16 @@ public class SQLServerTranslator extends JDBCTranslator { if (!query.getOrderBy().isEmpty()) sql.append('\n').append(translate(query.getOrderBy())); + if (query.getSelect().hasLimit()) { + if (query.hasOffset()) + sql.append('\n').append("OFFSET ").append(query.getOffset()).append(" ROWS"); + else + sql.append('\n').append("OFFSET 0 ROWS"); + sql.append(" FETCH NEXT ").append(query.getSelect().getLimit()).append(" ROWS ONLY"); + } else if (query.hasOffset()) { + sql.append('\n').append("OFFSET ").append(query.getOffset()).append(" ROWS"); + } + return sql.toString(); } @@ -165,9 +178,9 @@ public class SQLServerTranslator extends JDBCTranslator { String sql = null; for(int i = 0; i < clause.size(); i++) { - if (i == 0) { - sql = clause.getName() + (clause.distinctColumns() ? " DISTINCT" : "") + (clause.hasLimit() ? " TOP " + clause.getLimit() + " " : ""); - } else + if (i == 0) + sql = clause.getName() + (clause.distinctColumns() ? " DISTINCT" : ""); + else sql += " " + clause.getSeparator(i); sql += " " + translate(clause.get(i)); diff --git a/test/adql/parser/TestADQLParser.java b/test/adql/parser/TestADQLParser.java index 13ff99194cc46e9945b183c9994a52a66baf6eeb..b17a38c65d91711516a89420fb1f9d1843a70515 100644 --- a/test/adql/parser/TestADQLParser.java +++ b/test/adql/parser/TestADQLParser.java @@ -51,6 +51,45 @@ public class TestADQLParser { public void tearDown() throws Exception { } + @Test + public void testOffset() { + + // CASE: No OFFSET in ADQL-2.0 + ADQLParser parser = new ADQLParser(ADQLVersion.V2_0); + try { + parser.parseQuery("SELECT * FROM foo ORDER BY id OFFSET 10"); + fail("OFFSET should not be allowed with ADQL-2.0!"); + } catch(Exception ex) { + assertEquals(ParseException.class, ex.getClass()); + assertEquals(" Encountered \"OFFSET\". Was expecting one of: <EOF> \",\" \";\" \"ASC\" \"DESC\" ", ex.getMessage()); + } + + // CASE: OFFSET allowed in ADQL-2.1 + parser = new ADQLParser(ADQLVersion.V2_1); + try { + assertEquals("SELECT *\nFROM foo\nOFFSET 10", parser.parseQuery("SELECT * FROM foo OFFSET 10").toADQL()); + assertEquals("SELECT *\nFROM foo\nORDER BY id ASC\nOFFSET 10", parser.parseQuery("SELECT * FROM foo ORDER BY id OFFSET 10").toADQL()); + assertEquals("SELECT *\nFROM foo\nORDER BY id ASC\nOFFSET 0", parser.parseQuery("SELECT * FROM foo ORDER BY id OFFSET 0").toADQL()); + assertEquals("SELECT TOP 5 *\nFROM foo\nORDER BY id ASC\nOFFSET 10", parser.parseQuery("SELECT TOP 5 * FROM foo ORDER BY id OFFSET 10").toADQL()); + } catch(Exception ex) { + ex.printStackTrace(); + fail("Unexpected error with a valid OFFSET syntax! (see console for more details)"); + } + + // CASE: Only an unsigned integer constant is allowed + String[] offsets = new String[]{ "-1", "colOffset", "2*5" }; + String[] expectedErrors = new String[]{ " Encountered \"-\". Was expecting: <UNSIGNED_INTEGER> ", " Encountered \"colOffset\". Was expecting: <UNSIGNED_INTEGER> ", " Encountered \"*\". Was expecting one of: <EOF> \";\" " }; + for(int i = 0; i < offsets.length; i++) { + try { + parser.parseQuery("SELECT * FROM foo OFFSET " + offsets[i]); + fail("Incorrect offset expression (\"" + offsets[i] + "\"). This test should have failed."); + } catch(Exception ex) { + assertEquals(ParseException.class, ex.getClass()); + assertEquals(expectedErrors[i], ex.getMessage()); + } + } + } + @Test public void testColumnReference() { ADQLParser parser = new ADQLParser(); diff --git a/test/adql/query/constraint/TestIN.java b/test/adql/query/constraint/TestIN.java index ab385dbf0840dc3be68ac02acbf25a1e32dbeec3..4b3aab52b22a15e50ba936b90fa793a1e516caee 100644 --- a/test/adql/query/constraint/TestIN.java +++ b/test/adql/query/constraint/TestIN.java @@ -13,7 +13,6 @@ import adql.query.ADQLObject; import adql.query.ADQLOrder; import adql.query.ADQLQuery; import adql.query.ClauseSelect; -import adql.query.constraint.In; import adql.query.from.ADQLTable; import adql.query.operand.ADQLColumn; import adql.query.operand.ADQLOperand; @@ -28,20 +27,20 @@ public class TestIN { private static ADQLTranslator translator = null; @BeforeClass - public static void setUpBeforeClass(){ + public static void setUpBeforeClass() { translator = new PostgreSQLTranslator(); } @Test - public void testIN(){ + public void testIN() { // Test with a simple list of values (here, string constants): - In myIn = new In(new ADQLColumn("typeObj"), new ADQLOperand[]{new StringConstant("galaxy"),new StringConstant("star"),new StringConstant("planet"),new StringConstant("nebula")}, true); + In myIn = new In(new ADQLColumn("typeObj"), new ADQLOperand[]{ new StringConstant("galaxy"), new StringConstant("star"), new StringConstant("planet"), new StringConstant("nebula") }, true); // check the ADQL: assertEquals("typeObj NOT IN ('galaxy' , 'star' , 'planet' , 'nebula')", myIn.toADQL()); // check the SQL translation: - try{ + try { assertEquals(myIn.toADQL(), translator.translate(myIn)); - }catch(Exception ex){ + } catch(Exception ex) { ex.printStackTrace(); fail("This test should have succeeded because the IN statement is correct and theoretically well supported by the POSTGRESQL translator!"); } @@ -63,23 +62,23 @@ public class TestIN { // check the ADQL: assertEquals("typeObj NOT IN (SELECT DISTINCT TOP 10 typeObj\nFROM Objects\nORDER BY 1 ASC)", myIn.toADQL()); // check the SQL translation: - try{ - assertEquals("typeObj NOT IN (SELECT DISTINCT typeObj AS \"typeObj\"\nFROM Objects\nORDER BY 1 ASC\nLimit 10)", translator.translate(myIn)); - }catch(Exception ex){ + try { + assertEquals("typeObj NOT IN (SELECT DISTINCT typeObj AS \"typeObj\"\nFROM Objects\nORDER BY 1 ASC\nLIMIT 10)", translator.translate(myIn)); + } catch(Exception ex) { ex.printStackTrace(); fail("This test should have succeeded because the IN statement is correct and theoretically well supported by the POSTGRESQL translator!"); } // Test after replacement inside this IN statement: - IReplaceHandler sHandler = new SimpleReplaceHandler(true){ + IReplaceHandler sHandler = new SimpleReplaceHandler(true) { @Override - public boolean match(ADQLObject obj){ + public boolean match(ADQLObject obj) { return (obj instanceof ADQLColumn) && ((ADQLColumn)obj).getColumnName().equals("typeObj"); } @Override - public ADQLObject getReplacer(ADQLObject objToReplace){ + public ADQLObject getReplacer(ADQLObject objToReplace) { return new ADQLColumn("type"); } }; diff --git a/test/adql/translator/TestJDBCTranslator.java b/test/adql/translator/TestJDBCTranslator.java index 1df654f5a8f272eef7dc7e88fe536cbdd817edc5..0d11e374da834278c0d823ae394e3b60d41fb2cd 100644 --- a/test/adql/translator/TestJDBCTranslator.java +++ b/test/adql/translator/TestJDBCTranslator.java @@ -9,6 +9,8 @@ import org.junit.Test; import adql.db.DBType; import adql.db.FunctionDef; import adql.db.STCS.Region; +import adql.parser.ADQLParser; +import adql.parser.ADQLParser.ADQLVersion; import adql.parser.grammar.ParseException; import adql.query.IdentifierField; import adql.query.operand.ADQLColumn; @@ -34,6 +36,34 @@ public class TestJDBCTranslator { public void setUp() throws Exception { } + @Test + public void testTranslateOffset() { + JDBCTranslator tr = new AJDBCTranslator(); + ADQLParser parser = new ADQLParser(ADQLVersion.V2_1); + + try { + + // CASE: Only OFFSET + assertEquals("SELECT *\nFROM foo\nOFFSET 10", tr.translate(parser.parseQuery("Select * From foo OffSet 10"))); + + // CASE: Only OFFSET = 0 + assertEquals("SELECT *\nFROM foo\nOFFSET 0", tr.translate(parser.parseQuery("Select * From foo OffSet 0"))); + + // CASE: TOP + OFFSET + assertEquals("SELECT *\nFROM foo\nLIMIT 5\nOFFSET 10", tr.translate(parser.parseQuery("Select Top 5 * From foo OffSet 10"))); + + // CASE: TOP + ORDER BY + OFFSET + assertEquals("SELECT *\nFROM foo\nORDER BY id ASC\nLIMIT 5\nOFFSET 10", tr.translate(parser.parseQuery("Select Top 5 * From foo Order By id Asc OffSet 10"))); + + } catch(ParseException pe) { + pe.printStackTrace(System.err); + fail("Unexpected failed query parsing! (see console for more details)"); + } catch(Exception e) { + e.printStackTrace(System.err); + fail("There should have been no problem to translate a query with offset into SQL."); + } + } + @Test public void testTranslateStringConstant() { JDBCTranslator tr = new AJDBCTranslator(); diff --git a/test/adql/translator/TestSQLServerTranslator.java b/test/adql/translator/TestSQLServerTranslator.java index 7a523eed7338dde0de1cdb46892114a8db8a94ca..f8eda775f0bb598364a584d7052885bfa616d11a 100644 --- a/test/adql/translator/TestSQLServerTranslator.java +++ b/test/adql/translator/TestSQLServerTranslator.java @@ -14,6 +14,7 @@ import adql.db.DBTable; import adql.db.DefaultDBColumn; import adql.db.DefaultDBTable; import adql.parser.ADQLParser; +import adql.parser.ADQLParser.ADQLVersion; import adql.parser.SQLServer_ADQLQueryFactory; import adql.parser.grammar.ParseException; import adql.query.ADQLQuery; @@ -37,6 +38,34 @@ public class TestSQLServerTranslator { tables.add(t); } + @Test + public void testTranslateOffset() { + SQLServerTranslator tr = new SQLServerTranslator(); + ADQLParser parser = new ADQLParser(ADQLVersion.V2_1); + + try { + + // CASE: Only OFFSET + assertEquals("SELECT *\nFROM foo\nOFFSET 10 ROWS", tr.translate(parser.parseQuery("Select * From foo OffSet 10"))); + + // CASE: Only OFFSET = 0 + assertEquals("SELECT *\nFROM foo\nOFFSET 0 ROWS", tr.translate(parser.parseQuery("Select * From foo OffSet 0"))); + + // CASE: TOP + OFFSET + assertEquals("SELECT *\nFROM foo\nOFFSET 10 ROWS FETCH NEXT 5 ROWS ONLY", tr.translate(parser.parseQuery("Select Top 5 * From foo OffSet 10"))); + + // CASE: TOP + ORDER BY + OFFSET + assertEquals("SELECT *\nFROM foo\nORDER BY id ASC\nOFFSET 10 ROWS FETCH NEXT 5 ROWS ONLY", tr.translate(parser.parseQuery("Select Top 5 * From foo Order By id Asc OffSet 10"))); + + } catch(ParseException pe) { + pe.printStackTrace(System.err); + fail("Unexpected failed query parsing! (see console for more details)"); + } catch(Exception e) { + e.printStackTrace(System.err); + fail("There should have been no problem to translate a query with offset into SQL."); + } + } + @Test public void testNaturalJoin() { final String adqlquery = "SELECT id, name, aColumn, anotherColumn FROM aTable A NATURAL JOIN anotherTable B;";