Skip to content
Open
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
9 changes: 5 additions & 4 deletions core-spec/expression_language.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,13 @@ Ossie expressions support the following SQL constructs within any expression:
Standard SQL operator precedence applies (highest to lowest):

1. Parentheses `()`
2. Unary operators: `+`, `-`, `NOT`
2. Unary operators: `+`, `-`
3. Multiplication/Division: `*`, `/`, `%`
4. Addition/Subtraction: `+`, `-`
4. Addition/Subtraction/Concatenation: `+`, `-`, `||`
5. Comparison: `=`, `<>`, `<`, `>`, `<=`, `>=`, `LIKE`, `IN`, `BETWEEN`, `IS NULL`
6`AND`
7`OR`
6. `NOT`
7. `AND`
8. `OR`

---

Expand Down
21 changes: 21 additions & 0 deletions core/python/.pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
repos:
- repo: local
hooks:
- id: ossie-sql-ruff-check
name: ossie-sql ruff check
entry: bash -c 'cd core/python && uv run ruff check --fix src tests'
language: system
files: ^core/python/.*\.py$
pass_filenames: false
- id: ossie-sql-ruff-format
name: ossie-sql ruff format
entry: bash -c 'cd core/python && uv run ruff format src tests'
language: system
files: ^core/python/.*\.py$
pass_filenames: false
- id: ossie-sql-mypy
name: ossie-sql mypy (strict)
entry: bash -c 'cd core/python && uv run mypy --config-file ../../mypy.ini src tests'
language: system
files: ^core/python/.*\.py$
pass_filenames: false
13 changes: 13 additions & 0 deletions core/python/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.PHONY: format lint typecheck test

format:
uv run ruff format src tests

lint:
uv run ruff check src tests

typecheck:
uv run mypy --config-file ../../mypy.ini src tests

test:
uv run pytest
57 changes: 57 additions & 0 deletions core/python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# apache-ossie-sql

A [SQLGlot](https://github.com/tobymao/sqlglot) dialect implementing the
Ossie expression language defined in
[`core-spec/expression_language.md`](../../core-spec/expression_language.md)
("Ossie_SQL_2026").

This package covers only the expression grammar: a custom SQLGlot `Dialect`
(tokenizer/parser/generator) so `sqlglot.parse_one(sql, read="ossie")` parses
and round-trips the spec's SQL subset (aggregate/window/date/string/math/
conditional functions, typed literals, `CASE`, `CAST`/`TRY_CAST`, etc.), plus
a validator that rejects the constructs the spec explicitly disallows
(`SELECT`/`FROM`/`JOIN`, `GROUP BY`, `WHERE`, subqueries, CTEs, set
operations, DDL/DML). Wiring the dialect into the Ossie YAML model (the
spec's "Changes to YAML" section) is out of scope here.

## Development

This package uses [`uv`](https://docs.astral.sh/uv/) for dependency
management.

```bash
uv sync

# Run the test suite
uv run pytest

# Format code (auto-fixes in place)
uv run ruff format src tests

# Check formatting without modifying files
uv run ruff format --check src tests

# Lint
uv run ruff check src tests

# Type-check (strict; rules come from the shared repo-root mypy.ini)
uv run mypy --config-file ../../mypy.ini src tests
```

Or via the `Makefile`:

```bash
make format
make lint
make typecheck
make test
```

### Enforcing formatting/lint/type-checking locally

Install the pre-commit hooks scoped to this package so `ruff format`,
`ruff check`, and `mypy` run automatically before each commit:

```bash
uv run pre-commit install -c core/python/.pre-commit-config.yaml
```
31 changes: 31 additions & 0 deletions core/python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "apache-ossie-sql"
version = "0.1.0"
description = "SQLGlot dialect for the Ossie expression language (core-spec/expression_language.md)"
license = { text = "Apache-2.0" }
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"sqlglot>=25.0",
]

[dependency-groups]
dev = [
"pytest>=8.0",
"mypy>=1.10",
"ruff>=0.11",
]

[tool.hatch.build.targets.wheel]
packages = ["src/ossie_sql"]

[tool.ruff]
extend = "../../ruff.toml"

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
44 changes: 44 additions & 0 deletions core/python/src/ossie_sql/__init__.py
Original file line number Diff line number Diff line change
@@ -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.

"""SQLGlot dialect and validation helpers for the Ossie expression language.

See ``core-spec/expression_language.md`` for the language spec this package
implements, and this package's README for scope and usage.
"""

from ossie_sql.dialect import Ossie
from ossie_sql.functions import ComplianceLevel, compliance_level
from ossie_sql.identifiers import (
InvalidIdentifierError,
identifiers_equal,
normalize_identifier,
normalize_identifier_text,
)
from ossie_sql.validate import UnsupportedConstructError, validate_expression

__all__ = [
"ComplianceLevel",
"InvalidIdentifierError",
"Ossie",
"UnsupportedConstructError",
"compliance_level",
"identifiers_equal",
"normalize_identifier",
"normalize_identifier_text",
"validate_expression",
]
205 changes: 205 additions & 0 deletions core/python/src/ossie_sql/dialect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# 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.

"""SQLGlot dialect for the Ossie expression language.

Implements the grammar defined in ``core-spec/expression_language.md``
("Ossie_SQL_2026"). Registers with SQLGlot as ``"ossie"``, so
``sqlglot.parse_one(sql, read="ossie")`` parses the spec's SQL subset and
``expression.sql(dialect="ossie")`` renders it back.
Comment on lines +20 to +23

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The dialect name in the json spec and in the consumers should then just be "ossie" ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I was going to update the json spec, etc. as a different PR. I'm trying to keep this one to just the SqlGlot dialect.


The spec is explicitly an ANSI SQL:2003 subset, so this dialect starts from
SQLGlot's default (ANSI-like) Tokenizer/Parser/Generator and only overrides
the handful of spec constructs that the default dialect either can't parse
in the spec's exact shape (``DATEADD``/``DATEDIFF``/``DATE_PART`` with a
bare, leading date-part argument) or renders back in a spelling the spec
doesn't define (e.g. ``STR_POSITION`` instead of ``POSITION ... IN``,
``APPROX_DISTINCT`` instead of ``APPROX_COUNT_DISTINCT``). Functions the spec
lists that SQLGlot has no dedicated AST node for (``IFF``, ``ZEROIFNULL``,
``NULLIFZERO``, ...) already round-trip correctly as-is via SQLGlot's generic
``exp.Anonymous`` fallback and need no customization here.
"""

from __future__ import annotations

from collections.abc import Callable, Sequence
from typing import ClassVar

from sqlglot import TokenType, exp
from sqlglot.dialects.dialect import Dialect, unit_to_var
from sqlglot.generator import Generator
from sqlglot.helper import seq_get
from sqlglot.parser import Parser
from sqlglot.tokens import Tokenizer

_FuncBuilder = Callable[[Sequence[exp.Expression]], exp.Expression]


def _build_date_delta(exp_class: type[exp.DateAdd] | type[exp.DateDiff]) -> _FuncBuilder:
"""Build a parser for ``FUNC(part, amount_or_start, date_or_end)``.

The spec puts the date-part identifier FIRST (``DATEADD(day, 7, d)``,
``DATEDIFF(day, d1, d2)``). SQLGlot's generic positional ``Func``
construction instead treats the *last* argument as the unit, so this
needs a dedicated builder rather than relying on the default mapping.
"""

def _builder(args: Sequence[exp.Expression]) -> exp.Expression:
return exp_class(this=seq_get(args, 2), expression=seq_get(args, 1), unit=seq_get(args, 0))

return _builder


def _build_date_part(args: Sequence[exp.Expression]) -> exp.Expression:
"""``DATE_PART(part, expr)`` is the spec's alternative spelling of
``EXTRACT(part FROM expr)``; unify both into the same ``exp.Extract``
node so callers see one canonical AST shape regardless of which
surface syntax was used.

``DATE_PART`` takes its part name as a quoted string (``'year'``) while
``EXTRACT`` takes a bare keyword (``YEAR``); normalize to the latter so
the merged AST always renders as valid ``EXTRACT(... FROM ...)`` syntax.
"""
part = seq_get(args, 0)
part_name = part.name if part is not None else ""
return exp.Extract(this=exp.var(part_name.upper()), expression=seq_get(args, 1))


def _build_dpipe(
*, this: exp.Expression | None = None, expression: exp.Expression | None = None
) -> exp.DPipe:
"""Give ``||`` the same precedence tier as binary ``+``/``-``.

SQLGlot's default parser groups ``||`` with the bitwise operators, one
tier looser than ``+``/``-``. The spec instead follows the common convention,
which both put ``||`` at the *same* tier as ``+``/``-``
(left-to-right, like the arithmetic operators around it) -- see
``_OssieParser.TERM`` below, which is what actually makes that happen;
this just reproduces the ``safe=True`` default SQLGlot's own DPipe
construction uses so behavior is otherwise unchanged.
"""
return exp.DPipe(this=this, expression=expression, safe=True)


class _OssieTokenizer(Tokenizer):
pass


class _OssieParser(Parser):
FUNCTIONS = {
**Parser.FUNCTIONS,
"DATEADD": _build_date_delta(exp.DateAdd),
"DATEDIFF": _build_date_delta(exp.DateDiff),
"DATE_PART": _build_date_part,
"APPROX_PERCENTILE": exp.ApproxQuantile.from_arg_list,
}
# SQLGlot infers Parser.TERM's type from its literal (dict[TokenType,
# type[Binary]]), so mypy sees adding a plain builder function as an
# incompatible override -- it isn't, at runtime _parse_term only ever
# calls `klass(this=..., expression=...)` generically. Silence the two
# resulting checks rather than fight SQLGlot's own inferred type.
TERM: ClassVar[dict[TokenType, Callable[..., exp.Expression]]] = { # type: ignore[assignment]
**Parser.TERM, # type: ignore[dict-item]
TokenType.DPIPE: _build_dpipe,
}


class _OssieGenerator(Generator):
TYPE_MAPPING = {
**Generator.TYPE_MAPPING,
exp.DataType.Type.TIMESTAMPNTZ: "TIMESTAMP_NTZ",
}

def dateadd_sql(self, expression: exp.DateAdd) -> str:
return self.func("DATEADD", unit_to_var(expression), expression.expression, expression.this)

def datediff_sql(self, expression: exp.DateDiff) -> str:
return self.func(
"DATEDIFF", unit_to_var(expression), expression.expression, expression.this
)

def strposition_sql(self, expression: exp.StrPosition) -> str:
this = self.sql(expression, "this")
substr = self.sql(expression, "substr")
return f"POSITION({substr} IN {this})"

def startswith_sql(self, expression: exp.StartsWith) -> str:
return self.func("STARTSWITH", expression.this, expression.expression)

def endswith_sql(self, expression: exp.EndsWith) -> str:
return self.func("ENDSWITH", expression.this, expression.expression)

def approxdistinct_sql(self, expression: exp.ApproxDistinct) -> str:
return self.func("APPROX_COUNT_DISTINCT", expression.this, expression.args.get("accuracy"))

def variancepop_sql(self, expression: exp.VariancePop) -> str:
# SQLGlot's own canonical spelling ("VARIANCE_POP") isn't a name the
# spec recognizes at all; the spec only defines "VAR_POP".
return self.func("VAR_POP", expression.this)

def dayofyear_sql(self, expression: exp.DayOfYear) -> str:
# Ditto: the spec defines "DAYOFYEAR", not SQLGlot's "DAY_OF_YEAR".
return self.func("DAYOFYEAR", expression.this)

def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str:
# The spec calls this "APPROX_PERCENTILE"; SQLGlot's canonical name
# ("APPROX_QUANTILE") isn't a spelling the spec defines.
return self.func("APPROX_PERCENTILE", expression.this, expression.args.get("quantile"))

def not_sql(self, expression: exp.Not) -> str:
# Render the compact `NOT IN`/`IS NOT` forms the spec documents,
# instead of SQLGlot's generic `NOT (x IN (...))` / `NOT (x IS NULL)`.
this = expression.this
if isinstance(this, exp.In):
return self.in_sql(this).replace(" IN ", " NOT IN ", 1)
if isinstance(this, exp.Is):
return self.binary(this, "IS NOT")
return super().not_sql(expression)

def tochar_sql(self, expression: exp.ToChar) -> str:
return self.func("TO_CHAR", expression.this, expression.args.get("format"))

def cast_sql(self, expression: exp.Cast, safe_prefix: str | None = None) -> str:
# Prefer the compact typed-literal form (`DATE '...'`) for a string
# literal cast to DATE/TIME/TIMESTAMP/TIMESTAMP_NTZ -- the spec's
# primary documented construction syntax for these types.
to = expression.to
this = expression.this
if (
not safe_prefix
and isinstance(this, exp.Literal)
and this.is_string
and to.is_type(
exp.DataType.Type.DATE,
exp.DataType.Type.TIME,
exp.DataType.Type.TIMESTAMP,
exp.DataType.Type.TIMESTAMPNTZ,
)
):
return f"{self.sql(to)} {self.sql(this)}"
return super().cast_sql(expression, safe_prefix=safe_prefix)


class Ossie(Dialect):
"""The Ossie_SQL_2026 expression dialect."""

Tokenizer = _OssieTokenizer
Parser = _OssieParser
Generator = _OssieGenerator


__all__ = ["Ossie"]
Loading