diff --git a/asterixdb/asterix-app/src/main/java/org/apache/asterix/hyracks/bootstrap/CCApplication.java b/asterixdb/asterix-app/src/main/java/org/apache/asterix/hyracks/bootstrap/CCApplication.java index 6d17fef21c0..3ec3add2338 100644 --- a/asterixdb/asterix-app/src/main/java/org/apache/asterix/hyracks/bootstrap/CCApplication.java +++ b/asterixdb/asterix-app/src/main/java/org/apache/asterix/hyracks/bootstrap/CCApplication.java @@ -41,6 +41,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentMap; +import org.apache.asterix.api.http.IApiServerRegistrant; import org.apache.asterix.api.http.IQueryWebServerRegistrant; import org.apache.asterix.api.http.server.ActiveRequestsServlet; import org.apache.asterix.api.http.server.ActiveStatsApiServlet; @@ -361,6 +362,9 @@ protected HttpServer setupJSONAPIServer(ExternalProperties externalProperties) t addServlet(jsonAPIServer, Servlets.CLUSTER_STATE_CC_DETAIL); // must not precede add of CLUSTER_STATE addServlet(jsonAPIServer, Servlets.DIAGNOSTICS); addServlet(jsonAPIServer, Servlets.ACTIVE_STATS); + // Load extension servlets registered via ServiceLoader (e.g., NL2SQL++ from asterix-spidersilk) + ServiceLoader.load(IApiServerRegistrant.class) + .forEach(registrant -> registrant.register(appCtx, jsonAPIServer)); return jsonAPIServer; } diff --git a/asterixdb/asterix-common/src/main/java/org/apache/asterix/api/http/IApiServerRegistrant.java b/asterixdb/asterix-common/src/main/java/org/apache/asterix/api/http/IApiServerRegistrant.java new file mode 100644 index 00000000000..eec13e4ebe7 --- /dev/null +++ b/asterixdb/asterix-common/src/main/java/org/apache/asterix/api/http/IApiServerRegistrant.java @@ -0,0 +1,31 @@ +/* + * 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.asterix.api.http; + +/** + * Extension point for registering servlets on the JSON API server (default port 19002). + * Implementations are discovered via {@link java.util.ServiceLoader}. + * + * To register a servlet, create an implementation of this interface and declare it in: + * {@code META-INF/services/org.apache.asterix.api.http.IApiServerRegistrant} + * + * @see IQueryWebServerRegistrant for the equivalent mechanism on the query web server (port 19006) + */ +public interface IApiServerRegistrant extends IServletRegistrant { +} diff --git a/asterixdb/asterix-common/src/main/java/org/apache/asterix/common/utils/Servlets.java b/asterixdb/asterix-common/src/main/java/org/apache/asterix/common/utils/Servlets.java index 5edc18642d3..2e51bb8ec68 100644 --- a/asterixdb/asterix-common/src/main/java/org/apache/asterix/common/utils/Servlets.java +++ b/asterixdb/asterix-common/src/main/java/org/apache/asterix/common/utils/Servlets.java @@ -23,6 +23,7 @@ public class Servlets { public static final String QUERY_STATUS = "/query/service/status/*"; public static final String QUERY_RESULT = "/query/service/result/*"; public static final String QUERY_SERVICE = "/query/service"; + public static final String NL2SQL_SERVICE = "/query/nl2sql"; public static final String CONNECTOR = "/connector"; public static final String REBALANCE = "/admin/rebalance"; public static final String SHUTDOWN = "/admin/shutdown"; diff --git a/asterixdb/asterix-spidersilk/pom.xml b/asterixdb/asterix-spidersilk/pom.xml index b41ba2d8985..e0ec2df1b77 100644 --- a/asterixdb/asterix-spidersilk/pom.xml +++ b/asterixdb/asterix-spidersilk/pom.xml @@ -21,12 +21,56 @@ asterix-spidersilk asterix-spidersilk + + ${basedir}/.. + + org.apache.asterix apache-asterixdb 0.9.10-SNAPSHOT + + + org.apache.asterix + asterix-common + ${project.version} + + + org.apache.asterix + asterix-metadata + ${project.version} + + + org.apache.asterix + asterix-om + ${project.version} + + + org.apache.hyracks + hyracks-http + + + io.netty + netty-codec-http + + + com.fasterxml.jackson.core + jackson-databind + + + org.apache.logging.log4j + log4j-api + + + + junit + junit + test + + + diff --git a/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/api/INl2SqlTranslator.java b/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/api/INl2SqlTranslator.java new file mode 100644 index 00000000000..3aa3e7e8ccd --- /dev/null +++ b/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/api/INl2SqlTranslator.java @@ -0,0 +1,57 @@ +/* + * 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.asterix.spidersilk.api; + +/** + * Core interface for natural language to SQL++ translation. + * + * Implementations are model-agnostic: any LLM backend (OpenAI, Ollama, etc.) + * can be used by providing a different implementation. The LangChain4j framework + * is used internally to manage LLM communication, prompt templating, and retries. + * + *

Usage example: + *

+ *   INl2SqlTranslator translator = new LangChain4jTranslator(config);
+ *   SchemaContext schema = schemaBuilder.buildContext("TinySocial");
+ *   String sqlpp = translator.translate("Find all tweets mentioning AsterixDB", schema);
+ *   // sqlpp => "SELECT VALUE t FROM TweetMessages t WHERE t.message_text LIKE '%AsterixDB%'"
+ * 
+ */ +public interface INl2SqlTranslator { + + /** + * Translates a natural language query into an executable SQL++ statement. + * + * The implementation should: + *
    + *
  1. Build a schema-aware prompt from {@code schemaContext}
  2. + *
  3. Call the configured LLM to generate a SQL++ candidate
  4. + *
  5. Validate the candidate using the AsterixDB SQL++ parser
  6. + *
  7. Retry with error feedback if validation fails (up to a configured max)
  8. + *
+ * + * @param naturalLanguage the user's natural language query (non-null, non-empty) + * @param schemaContext schema information for the target dataverse; may be + * {@code null} if no dataverse is specified + * @return a syntactically valid SQL++ query string + * @throws Nl2SqlException if translation fails after exhausting retries, + * or if the LLM service is unavailable + */ + String translate(String naturalLanguage, SchemaContext schemaContext) throws Nl2SqlException; +} diff --git a/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/api/Nl2SqlException.java b/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/api/Nl2SqlException.java new file mode 100644 index 00000000000..a8993e7869e --- /dev/null +++ b/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/api/Nl2SqlException.java @@ -0,0 +1,41 @@ +/* + * 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.asterix.spidersilk.api; + +/** + * Thrown when natural language to SQL++ translation fails. + * Common causes: + *
    + *
  • LLM service unavailable or misconfigured
  • + *
  • Generated SQL++ fails syntax validation after max retries
  • + *
  • Input natural language query is ambiguous or unsupported
  • + *
+ */ +public class Nl2SqlException extends Exception { + + private static final long serialVersionUID = 1L; + + public Nl2SqlException(String message) { + super(message); + } + + public Nl2SqlException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/api/SchemaContext.java b/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/api/SchemaContext.java new file mode 100644 index 00000000000..76fa0c41f70 --- /dev/null +++ b/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/api/SchemaContext.java @@ -0,0 +1,78 @@ +/* + * 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.asterix.spidersilk.api; + +import java.util.Collections; +import java.util.List; + +/** + * Encapsulates the database schema information extracted from AsterixDB metadata. + * This context is injected into the LLM prompt to enable schema-aware SQL++ generation. + * + * The schema is extracted from the {@code asterix-metadata} module via MetadataManager, + * including Dataset definitions, type information, and index metadata. + */ +public class SchemaContext { + + private final String dataverse; + private final List datasetDescriptions; + + public SchemaContext(String dataverse, List datasetDescriptions) { + this.dataverse = dataverse; + this.datasetDescriptions = Collections.unmodifiableList(new java.util.ArrayList<>(datasetDescriptions)); + } + + /** + * @return the target dataverse name + */ + public String getDataverse() { + return dataverse; + } + + /** + * @return human-readable schema descriptions for each dataset in the dataverse, + * formatted for inclusion in an LLM prompt + */ + public List getDatasetDescriptions() { + return datasetDescriptions; + } + + /** + * Renders the schema context as a prompt-ready string. + * Example output: + *
+     * Dataverse: TinySocial
+     * Dataset TweetMessages (tweetid: bigint, sender-location: point, text: string, ...)
+     * Dataset FacebookUsers (id: bigint, name: string, employment: [object], ...)
+     * 
+ */ + public String toPromptString() { + StringBuilder sb = new StringBuilder(); + sb.append("Dataverse: ").append(dataverse).append('\n'); + for (String desc : datasetDescriptions) { + sb.append(desc).append('\n'); + } + return sb.toString(); + } + + @Override + public String toString() { + return "SchemaContext{dataverse='" + dataverse + "', datasets=" + datasetDescriptions.size() + "}"; + } +} diff --git a/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/servlet/NL2SqlServlet.java b/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/servlet/NL2SqlServlet.java new file mode 100644 index 00000000000..6e748c3de22 --- /dev/null +++ b/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/servlet/NL2SqlServlet.java @@ -0,0 +1,141 @@ +/* + * 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.asterix.spidersilk.servlet; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.concurrent.ConcurrentMap; + +import org.apache.asterix.spidersilk.api.INl2SqlTranslator; +import org.apache.asterix.spidersilk.api.Nl2SqlException; +import org.apache.asterix.spidersilk.api.SchemaContext; +import org.apache.hyracks.http.api.IServletRequest; +import org.apache.hyracks.http.api.IServletResponse; +import org.apache.hyracks.http.server.AbstractServlet; +import org.apache.hyracks.http.server.utils.HttpUtil; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import io.netty.handler.codec.http.HttpResponseStatus; + +/** + * HTTP servlet exposing the NL2SQL++ translation API on the JSON API server. + * + *

Endpoint: {@code POST /query/nl2sql} + * + *

Request parameters (form or JSON body): + *

    + *
  • {@code statement} (required) — the natural language query
  • + *
  • {@code dataverse} (optional) — target dataverse for schema context
  • + *
+ * + *

Response (JSON): + *

+ * {
+ *   "sqlpp":   "SELECT VALUE t FROM TweetMessages t WHERE ...",
+ *   "status":  "success"
+ * }
+ * 
+ * + *

When the {@code INl2SqlTranslator} implementation is not yet available, + * the endpoint returns HTTP 501 (Not Implemented) with an informative message, + * allowing the servlet to be registered and tested without a live LLM backend. + */ +public class NL2SqlServlet extends AbstractServlet { + + private static final Logger LOGGER = LogManager.getLogger(); + protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** Request parameter name for the natural language query. */ + public static final String PARAM_STATEMENT = "statement"; + /** Optional request parameter specifying the target dataverse. */ + public static final String PARAM_DATAVERSE = "dataverse"; + + /** + * The translator is injected at construction time and may be {@code null} + * until a concrete LLM implementation is provided (Phase 2 of development). + */ + private final INl2SqlTranslator translator; + + public NL2SqlServlet(ConcurrentMap ctx, String[] paths, INl2SqlTranslator translator) { + super(ctx, paths); + this.translator = translator; + } + + @Override + protected void post(IServletRequest request, IServletResponse response) throws IOException { + String naturalLanguage = request.getParameter(PARAM_STATEMENT); + String dataverse = request.getParameter(PARAM_DATAVERSE); + + if (naturalLanguage == null || naturalLanguage.isBlank()) { + sendError(request, response, HttpResponseStatus.BAD_REQUEST, "Parameter 'statement' is required."); + return; + } + + if (translator == null) { + sendError(request, response, HttpResponseStatus.NOT_IMPLEMENTED, + "NL2SQL++ translator is not yet configured. " + + "Set nl2sql.model.type and related properties in cc.conf and restart the server."); + return; + } + + try { + // Build schema context from metadata if a dataverse is provided. + // SchemaContextBuilder integration will be added in the next phase. + SchemaContext schemaContext = dataverse != null ? new SchemaContext(dataverse, java.util.List.of()) : null; + + String sqlpp = translator.translate(naturalLanguage, schemaContext); + + HttpUtil.setContentType(response, HttpUtil.ContentType.APPLICATION_JSON, request); + response.setStatus(HttpResponseStatus.OK); + + ObjectNode result = OBJECT_MAPPER.createObjectNode(); + result.put("sqlpp", sqlpp); + result.put("status", "success"); + + PrintWriter writer = response.writer(); + writer.write(result.toString()); + writer.flush(); + + } catch (Nl2SqlException e) { + LOGGER.warn("NL2SQL translation failed for query: {}", naturalLanguage, e); + sendError(request, response, HttpResponseStatus.INTERNAL_SERVER_ERROR, e.getMessage()); + } + } + + @Override + protected void get(IServletRequest request, IServletResponse response) throws IOException { + sendError(request, response, HttpResponseStatus.METHOD_NOT_ALLOWED, "Use POST with parameter 'statement'."); + } + + private void sendError(IServletRequest request, IServletResponse response, HttpResponseStatus status, + String message) throws IOException { + HttpUtil.setContentType(response, HttpUtil.ContentType.APPLICATION_JSON, request); + response.setStatus(status); + ObjectNode error = OBJECT_MAPPER.createObjectNode(); + error.put("status", "error"); + error.put("message", message); + PrintWriter writer = response.writer(); + writer.write(error.toString()); + writer.flush(); + } +} diff --git a/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/servlet/NL2SqlServletRegistrant.java b/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/servlet/NL2SqlServletRegistrant.java new file mode 100644 index 00000000000..9f0e1a74559 --- /dev/null +++ b/asterixdb/asterix-spidersilk/src/main/java/org/apache/asterix/spidersilk/servlet/NL2SqlServletRegistrant.java @@ -0,0 +1,44 @@ +/* + * 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.asterix.spidersilk.servlet; + +import org.apache.asterix.api.http.IApiServerRegistrant; +import org.apache.asterix.common.dataflow.ICcApplicationContext; +import org.apache.asterix.common.utils.Servlets; +import org.apache.hyracks.http.server.HttpServer; + +/** + * Registers the {@link NL2SqlServlet} on the JSON API server via the + * {@link IApiServerRegistrant} ServiceLoader extension point. + * + * This class is discovered automatically at runtime through: + * {@code META-INF/services/org.apache.asterix.api.http.IApiServerRegistrant} + * + * No modification to {@code CCApplication.java} is required beyond the + * one-time addition of the ServiceLoader call in {@code setupJSONAPIServer()}. + */ +public class NL2SqlServletRegistrant implements IApiServerRegistrant { + + @Override + public void register(ICcApplicationContext appCtx, HttpServer apiServer) { + // The translator is null here; it will be initialized from configuration + // in a follow-up phase when LangChain4j integration is added. + apiServer.addServlet(new NL2SqlServlet(apiServer.ctx(), new String[] { Servlets.NL2SQL_SERVICE }, null)); + } +} diff --git a/asterixdb/asterix-spidersilk/src/main/resources/META-INF/services/org.apache.asterix.api.http.IApiServerRegistrant b/asterixdb/asterix-spidersilk/src/main/resources/META-INF/services/org.apache.asterix.api.http.IApiServerRegistrant new file mode 100644 index 00000000000..0a4c6a71fde --- /dev/null +++ b/asterixdb/asterix-spidersilk/src/main/resources/META-INF/services/org.apache.asterix.api.http.IApiServerRegistrant @@ -0,0 +1,19 @@ +# +# 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. +# +org.apache.asterix.spidersilk.servlet.NL2SqlServletRegistrant diff --git a/asterixdb/asterix-spidersilk/src/test/java/org/apache/asterix/spidersilk/NL2SqlServletTest.java b/asterixdb/asterix-spidersilk/src/test/java/org/apache/asterix/spidersilk/NL2SqlServletTest.java new file mode 100644 index 00000000000..9ee95c7cedd --- /dev/null +++ b/asterixdb/asterix-spidersilk/src/test/java/org/apache/asterix/spidersilk/NL2SqlServletTest.java @@ -0,0 +1,99 @@ +/* + * 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.asterix.spidersilk; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.asterix.spidersilk.api.INl2SqlTranslator; +import org.apache.asterix.spidersilk.api.Nl2SqlException; +import org.apache.asterix.spidersilk.api.SchemaContext; +import org.junit.Assert; +import org.junit.Test; + +/** + * Unit tests for the NL2SQL++ module skeleton. + * + * These tests verify the core API contracts without requiring a running AsterixDB + * instance or a live LLM service. Full integration tests will be added in Phase 2 + * when LangChain4j translation is implemented. + */ +public class NL2SqlServletTest { + + @Test + public void testSchemaContextToPromptString() { + SchemaContext ctx = + new SchemaContext("TinySocial", Arrays.asList("Dataset TweetMessages (tweetid: bigint, text: string)", + "Dataset FacebookUsers (id: bigint, name: string)")); + + String prompt = ctx.toPromptString(); + + Assert.assertTrue("Prompt should contain dataverse name", prompt.contains("TinySocial")); + Assert.assertTrue("Prompt should contain TweetMessages dataset", prompt.contains("TweetMessages")); + Assert.assertTrue("Prompt should contain FacebookUsers dataset", prompt.contains("FacebookUsers")); + } + + @Test + public void testSchemaContextImmutable() { + List descriptions = new ArrayList<>(); + descriptions.add("Dataset Foo (id: bigint)"); + SchemaContext ctx = new SchemaContext("TestDV", descriptions); + + // Modifying the original list should not affect the SchemaContext + descriptions.add("Dataset Bar (id: bigint)"); + + Assert.assertEquals("SchemaContext should hold an immutable copy of the descriptions", 1, + ctx.getDatasetDescriptions().size()); + } + + @Test + public void testNl2SqlExceptionMessage() { + Nl2SqlException ex = new Nl2SqlException("LLM service unavailable"); + Assert.assertEquals("LLM service unavailable", ex.getMessage()); + } + + @Test + public void testNl2SqlExceptionWithCause() { + RuntimeException cause = new RuntimeException("connection refused"); + Nl2SqlException ex = new Nl2SqlException("Translation failed", cause); + + Assert.assertEquals("Translation failed", ex.getMessage()); + Assert.assertSame(cause, ex.getCause()); + } + + /** + * Verifies that a mock implementation of INl2SqlTranslator correctly + * returns a SQL++ string. This ensures the interface contract is stable. + */ + @Test + public void testTranslatorInterfaceContract() throws Nl2SqlException { + INl2SqlTranslator mockTranslator = + (nl, schema) -> "SELECT VALUE t FROM TweetMessages t WHERE t.text LIKE '%" + nl + "%'"; + + SchemaContext ctx = + new SchemaContext("TinySocial", Arrays.asList("Dataset TweetMessages (tweetid: bigint, text: string)")); + + String result = mockTranslator.translate("AsterixDB", ctx); + + Assert.assertNotNull("Translator must return a non-null SQL++ string", result); + Assert.assertTrue("Result should reference the dataset", result.contains("TweetMessages")); + Assert.assertTrue("Result should be a SELECT statement", result.startsWith("SELECT")); + } +}