Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,10 @@ http-client.env.json
# Coding agent files (could be symlinks)
.claude
.clinerules
memory-bank
memory-bank

# jqwik property-based testing database
.jqwik-database

# Kiro IDE spec files
.kiro/
6 changes: 6 additions & 0 deletions api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ dependencies {
testImplementation group: 'org.mockito', name: 'mockito-core', version: "${mockito_version}"
testImplementation group: 'org.apache.calcite', name: 'calcite-testkit', version: '1.41.0'

testImplementation('org.junit.jupiter:junit-jupiter:5.9.3')
testImplementation('net.jqwik:jqwik:1.9.2')
Comment on lines +23 to +24
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jqwik 1.9.2 is not compatible with JUnit 5.9.3

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[optional] #1974 suggested to eliminate JUnit5, I prefer to not introduce junit-jupiter in api module.

testRuntimeOnly('org.junit.platform:junit-platform-launcher')
testRuntimeOnly('org.junit.vintage:junit-vintage-engine')

testFixturesApi group: 'junit', name: 'junit', version: '4.13.2'
testFixturesApi group: 'org.hamcrest', name: 'hamcrest', version: "${hamcrest_version}"
}
Expand All @@ -43,6 +48,7 @@ spotless {
}

test {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed"
exceptionFormat "full"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.sql.api.dialect;

/**
* Central constants for dialect names. Avoids scattered string literals across the codebase. All
* dialect name strings used in registration, routing, and error messages should reference constants
* from this class.
*/
public final class DialectNames {

/** The ClickHouse SQL dialect name used in the {@code ?dialect=clickhouse} query parameter. */
public static final String CLICKHOUSE = "clickhouse";

private DialectNames() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.sql.api.dialect;

import org.apache.calcite.sql.SqlDialect;
import org.apache.calcite.sql.SqlOperatorTable;
import org.apache.calcite.sql.parser.SqlParser;

/**
* A self-contained dialect implementation providing all components needed to parse, translate, and
* unparse queries in a specific SQL dialect.
*
* <p>Each dialect plugin supplies a {@link QueryPreprocessor} for stripping dialect-specific
* clauses, a {@link SqlParser.Config} for dialect-aware parsing, a {@link SqlOperatorTable} for
* dialect function resolution, and a {@link SqlDialect} subclass for unparsing RelNode plans back
* to dialect-compatible SQL.
*
* <h3>Thread-safety</h3>
*
* Implementations MUST be thread-safe. All methods may be called concurrently from multiple
* request-handling threads. Returned components (preprocessor, operator table, etc.) MUST also be
* thread-safe or stateless.
*
* <h3>Lifecycle</h3>
*
* <ol>
* <li><b>Construction</b>: Plugin is instantiated during system startup.
* <li><b>Registration</b>: Plugin is registered with {@link DialectRegistry}.
* <li><b>Serving</b>: Plugin methods are called concurrently for each dialect query.
* <li><b>Shutdown</b>: No explicit close — plugins should not hold external resources.
* </ol>
*
* <h3>Extension</h3>
*
* Third-party dialects can implement this interface and register via {@link
* DialectRegistry#register} during plugin initialization, or via ServiceLoader SPI in a future
* release.
*/
public interface DialectPlugin {

/**
* Returns the unique dialect name used in the {@code ?dialect=} query parameter (e.g.,
* "clickhouse"). This name is used for registration in the {@link DialectRegistry} and for
* matching against the dialect parameter in incoming REST requests.
*
* <p>The returned value must be non-null, non-empty, and stable across invocations.
*
* @return the dialect name, never {@code null}
*/
String dialectName();

/**
* Returns the preprocessor that strips or transforms dialect-specific clauses from the raw query
* string before it reaches the Calcite SQL parser.
*
* <p>The returned preprocessor must be thread-safe or stateless, as it may be invoked
* concurrently from multiple request-handling threads.
*
* @return the query preprocessor for this dialect, never {@code null}
*/
QueryPreprocessor preprocessor();

/**
* Returns the Calcite {@link SqlParser.Config} for this dialect, controlling quoting style, case
* sensitivity, and other parser behavior.
*
* <p>The returned config is typically an immutable value object and is safe for concurrent use.
*
* @return the parser configuration for this dialect, never {@code null}
*/
SqlParser.Config parserConfig();

/**
* Returns the {@link SqlOperatorTable} containing dialect-specific function definitions. This
* table is chained with Calcite's default operator table during query validation so that
* dialect-specific functions are resolved alongside standard SQL functions.
*
* <p>The returned operator table must be thread-safe, as it may be queried concurrently from
* multiple request-handling threads.
*
* @return the operator table for this dialect, never {@code null}
*/
SqlOperatorTable operatorTable();

/**
* Returns the Calcite {@link SqlDialect} subclass used for unparsing RelNode logical plans back
* into SQL compatible with this dialect.
*
* <p>The returned dialect instance must be thread-safe or stateless.
*
* @return the SQL dialect for unparsing, never {@code null}
*/
SqlDialect sqlDialect();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.sql.api.dialect;

import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

/**
* Registry holding all available dialect plugins. Initialized at startup with built-in dialects.
*
* <p>Lifecycle: During plugin initialization, dialects are registered via {@link #register}. Once
* all built-in dialects are registered, {@link #freeze()} is called to convert the internal map to
* an immutable copy. After freezing, no new registrations are accepted and all lookups are lock-free
* via the immutable map.
*
* <p>Thread-safety: All public methods are safe for concurrent use. Before freeze, registration is
* synchronized. After freeze, {@link #resolve} and {@link #availableDialects} are lock-free reads
* against an immutable map.
*/
public class DialectRegistry {

private final Map<String, DialectPlugin> mutableDialects = new ConcurrentHashMap<>();
private volatile Map<String, DialectPlugin> dialects;
private volatile boolean frozen = false;

/**
* Register a dialect plugin. The dialect name is obtained from {@link
* DialectPlugin#dialectName()}.
*
* @param plugin the dialect plugin to register
* @throws IllegalStateException if the registry has been frozen after initialization
* @throws IllegalArgumentException if a dialect with the same name is already registered
*/
public synchronized void register(DialectPlugin plugin) {
if (frozen) {
throw new IllegalStateException("Registry is frozen after initialization");
}
String name = plugin.dialectName();
if (mutableDialects.containsKey(name)) {
throw new IllegalArgumentException("Dialect '" + name + "' is already registered");
}
mutableDialects.put(name, plugin);
}

/**
* Freeze the registry after startup. Converts the internal mutable map to an immutable copy for
* lock-free reads. After this call, {@link #register} will throw {@link IllegalStateException}.
*/
public synchronized void freeze() {
this.dialects = Map.copyOf(mutableDialects);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

public synchronized void freeze() {
  if (!frozen) {
    this.dialects = Map.copyOf(mutableDialects);
    this.frozen = true;
  }
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we clear the mutableDialects here?
mutableDialects.clear()

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: why we duplicate the dialects map to mutableDialects and immutableDialects? IMO, the CurrentHashMap mutableDialects is sufficient and no necessary to do "freeze" operation.

this.frozen = true;
}

/**
* Returns whether this registry has been frozen.
*
* @return true if {@link #freeze()} has been called
*/
public boolean isFrozen() {
return frozen;
}

/**
* Resolve a dialect by name. Uses the frozen immutable map if available, otherwise falls back to
* the mutable map (during initialization).
*
* @param dialectName the dialect name to look up
* @return an {@link Optional} containing the plugin if found, or empty if not registered
*/
public Optional<DialectPlugin> resolve(String dialectName) {
Map<String, DialectPlugin> snapshot = this.dialects;
if (snapshot != null) {
return Optional.ofNullable(snapshot.get(dialectName));
}
return Optional.ofNullable(mutableDialects.get(dialectName));
}

/**
* Returns the set of all registered dialect names.
*
* @return an unmodifiable set of the registered dialect names
*/
public Set<String> availableDialects() {
Map<String, DialectPlugin> snapshot = this.dialects;
if (snapshot != null) {
return snapshot.keySet();
}
return Set.copyOf(mutableDialects.keySet());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.sql.api.dialect;

/**
* Per-dialect preprocessor that transforms raw query strings before they reach the Calcite SQL
* parser. Implementations strip or transform dialect-specific clauses that Calcite cannot parse.
*/
public interface QueryPreprocessor {

/**
* Preprocess the raw query string, stripping or transforming dialect-specific clauses.
*
* @param query the raw query string
* @return the cleaned query string ready for Calcite parsing
*/
String preprocess(String query);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# ServiceLoader descriptor for DialectPlugin implementations.
#
# Built-in dialects (e.g., ClickHouse) are registered programmatically
# during plugin initialization and do not need entries here.
#
# Third-party dialect plugins packaged as separate JARs should include
# their own META-INF/services/org.opensearch.sql.api.dialect.DialectPlugin
# file listing their implementation class(es), one per line. For example:
#
# com.example.dialect.MyCustomDialectPlugin
#
# At startup, ServiceLoader.load(DialectPlugin.class) discovers all
# implementations on the classpath and registers them with the
# DialectRegistry before it is frozen.
Loading
Loading