From e4aaf95149730517bf0ea5aba36bba12e62d03ce Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Wed, 21 Oct 2026 14:51:14 +0330 Subject: [PATCH 1/3] Support `CREATE SCHEMA` --- rapidquery/ddl.py | 6 +- rapidquery/ddl.pyi | 56 ++++++++++++- src/ddl/mod.rs | 6 ++ src/ddl/schema.rs | 173 ++++++++++++++++++++++++++++++++++++++-- src/dialect/base.rs | 27 +++---- src/dialect/builtin.rs | 51 ++++++++++-- src/dialect/config.rs | 26 ++++++ src/expr/base.rs | 16 +--- src/expr/enum_/ident.rs | 10 +++ src/internal/repr.rs | 6 ++ 10 files changed, 338 insertions(+), 39 deletions(-) diff --git a/rapidquery/ddl.py b/rapidquery/ddl.py index 6e97d68..cc7e365 100644 --- a/rapidquery/ddl.py +++ b/rapidquery/ddl.py @@ -1,5 +1,9 @@ -from ._core.ddl import DataDefinitionLanguage +from ._core.ddl import ( + CreateSchema, + DataDefinitionLanguage, +) __all__ = [ + "CreateSchema", "DataDefinitionLanguage", ] diff --git a/rapidquery/ddl.pyi b/rapidquery/ddl.pyi index d513aa1..e16b5fe 100644 --- a/rapidquery/ddl.pyi +++ b/rapidquery/ddl.pyi @@ -1,6 +1,60 @@ """Data Definition Language""" +from typing import Self, final + +from rapidquery.dialect import Dialect +from rapidquery.expr import Ident, _IdentExtractor + class DataDefinitionLanguage: """Base of every DDL statement. Construct a subclass, not this class.""" - ... + def to_sql(self, dialect: Dialect) -> str: + """ + Render ``self`` with values written as SQL literals. + + Args: + dialect: Dialect that chooses quoting and literal spelling. + + Returns: + The SQL text. + + Raises: + RuntimeError: The dialect rejects this construct. + ValueError: A `CustomExpr` template has a different number of `{}` + slots than arguments. + """ + +@final +class CreateSchema(DataDefinitionLanguage): + """Represents ``CREATE SCHEMA`` statement.""" + + def __new__( + cls, + name: _IdentExtractor, + if_not_exists: bool = False, + authorization: str | None = None, + location: str | None = None, + ) -> Self: + """ + Construct a new ``CREATE SCHEMA`` statement. + + Args: + name: schema name e.g. `"analysis"`. Some dialects supports multipart names (like `"public"."analysis"`) + so there's no limit and it is in your control. + if_not_exists: Emits ``IF NOT EXISTS`` clause. Means do nothing if a schema with the same name + already exists. *If targeted dialect doesn't support this, it will be ignored.* + authorization: Emits ``AUTHORIZATION name`` clause. Some dialects allow us to names the owner of the new + schema. + location: Some dialects allow us to specify a location for the new schema. It binds the schema to a directory, + which can be a local FS path, HDFS, or even S3. + """ + ... + + @property + def name(self) -> Ident: ... + @property + def if_not_exists(self) -> bool: ... + @property + def authorization(self) -> str | None: ... + @property + def location(self) -> str | None: ... diff --git a/src/ddl/mod.rs b/src/ddl/mod.rs index 8c355e6..64c20a4 100644 --- a/src/ddl/mod.rs +++ b/src/ddl/mod.rs @@ -1,5 +1,8 @@ //! Data Definition Language +mod schema; +pub use schema::PyCreateSchema; + /// Base of every DDL statement. Construct a subclass, not this class. #[rapidquery_macros::pyclass("rapidquery.ddl.DataDefinitionLanguage", subclass)] #[derive(Debug, Clone, Copy)] @@ -9,4 +12,7 @@ pub struct PyDataDefinitionLanguage; pub mod ddl_mod { #[pymodule_export] use super::PyDataDefinitionLanguage; + + #[pymodule_export] + use super::PyCreateSchema; } diff --git a/src/ddl/schema.rs b/src/ddl/schema.rs index d10d43d..14549fc 100644 --- a/src/ddl/schema.rs +++ b/src/ddl/schema.rs @@ -1,10 +1,173 @@ -/// A `CREATE SCHEMA` statement. -#[rapidquery_macros::pyclass("rapidquery.ddl.CreateSchema")] +use crate::dialect::config; +use crate::expr::enum_::Ident; +use crate::expr::extractors::IdentExtractor; +use crate::internal::alias; +use crate::internal::repr; +use crate::internal::sql; + +/// Represent a `CREATE SCHEMA` statement. +#[rapidquery_macros::pyclass("rapidquery.ddl.CreateSchema", extends=super::PyDataDefinitionLanguage)] #[derive(Debug, Clone)] pub struct PyCreateSchema { - pub schema_name: arcstr::ArcStr, + /// Schema name e.g. `"datasets"` + pub name: Ident, + + /// Emits `IF NOT EXISTS`. pub if_not_exists: bool, - pub owner_name: Option, - pub comment: Option, + + /// Authorization name. + pub authorization: Option, + + /// Location pub location: Option, + // TODO: support embedded DDL statements +} + +impl sql::WriteSql for PyCreateSchema { + fn write_sql(&self, dialect: &crate::dialect::PyDialect, out: &mut W) -> sql::PrepareResult<()> { + if dialect.0.schema_features.contains(config::SchemaFeatures::UNSUPPORTED) { + return Err(rapidquery_macros::pyerr!( + PyRuntimeError, + "CREATE SCHEMA statement is not supported by this dialect" + ) + .into()); + } + + // CREATE SCHEMA + out.push_str("CREATE SCHEMA "); + + // ... [IF NOT EXISTS] + if self.if_not_exists + && dialect + .0 + .schema_features + .contains(config::SchemaFeatures::IF_NOT_EXISTS) + { + out.push_str("IF NOT EXISTS "); + } + + // ... [name] AUTHORIZATION [owner_name] + match dialect.0.schema_authorization_style { + config::SchemaAuthorizationStyle::Unsupported => { + if self.authorization.is_some() { + return Err(rapidquery_macros::pyerr!( + PyRuntimeError, + "authorization is not supported in CREATE SCHEMA by this dialect" + ) + .into()); + } + + self.name.write_sql(dialect, out)?; + } + config::SchemaAuthorizationStyle::Optional => { + self.name.write_sql(dialect, out)?; + + if let Some(x) = &self.authorization { + out.push_str(" AUTHORIZATION"); + dialect.0.write_identifier(out, x)?; + } + } + config::SchemaAuthorizationStyle::Forced => { + // If we have authorization, it should be equal to name. + if let Some(x) = &self.authorization { + let parts = self.name.parts(); + + if parts.len() > 1 || parts[0] != x.as_str() { + return Err(rapidquery_macros::pyerr!( + PyRuntimeError, + "AUTHORIZATION in CREATE SCHEMA is required by this dialect and schema \ + name needs to be equal to authorization. omit authorization, or set same name and authorization." + ) + .into()); + } + } + + out.push_str("AUTHORIZATION "); + self.name.write_sql(dialect, out)?; + } + } + + if let Some(x) = &self.location { + // ... (LOCATION | MANAGED LOCATION | EXTERNAL_VOLUME =) location + match dialect.0.schema_location_style { + config::SchemaLocationStyle::Unsupported => { + return Err(rapidquery_macros::pyerr!( + PyRuntimeError, + "location is not supported in CREATE SCHEMA by this dialect" + ) + .into()); + } + config::SchemaLocationStyle::LocationKeyword => { + out.push_str(" LOCATION "); + dialect.0.write_string_literal(out, x)?; + } + config::SchemaLocationStyle::ManagedLocationKeyword => { + out.push_str(" MANAGED LOCATION "); + dialect.0.write_string_literal(out, x)?; + } + config::SchemaLocationStyle::ExternalVolume => { + out.push_str(" EXTERNAL_VOLUME = "); + dialect.0.write_string_literal(out, x)?; + } + } + } + + Ok(()) + } +} + +#[pyo3::pymethods] +impl PyCreateSchema { + #[new] + #[pyo3(signature=(name, if_not_exists=false, authorization=None, location=None))] + fn new( + name: IdentExtractor, + if_not_exists: bool, + authorization: Option<&str>, + location: Option<&str>, + ) -> pyo3::PyClassInitializer { + let result = Self { + name: name.0, + if_not_exists, + authorization: authorization.map(Into::into), + location: location.map(Into::into), + }; + pyo3::PyClassInitializer::from(super::PyDataDefinitionLanguage).add_subclass(result) + } + + #[getter] + fn name<'a>(&self, py: pyo3::Python<'a>) -> pyo3::PyResult> { + crate::expr::base::into_subclasses(py, self.name.clone().into()) + } + + #[getter] + fn if_not_exists(&self) -> bool { + self.if_not_exists + } + + #[getter] + fn authorization(&self) -> Option { + self.authorization.as_ref().map(|x| x.to_string()) + } + + #[getter] + fn location(&self) -> Option { + self.location.as_ref().map(|x| x.to_string()) + } + + /// Render this expression as raw SQL (values inlined as literals). + fn to_sql(&self, py: pyo3::Python, dialect: crate::dialect::PyDialect) -> pyo3::PyResult { + sql::write_sql(py, self, &dialect).map_err(Into::into) + } + + fn __repr__(&self) -> String { + let mut fmt = repr::ReprFormatter::new_call("CreateSchema"); + + fmt.positional(self.name.python_repr()) + .keyword_true("if_not_exists", self.if_not_exists) + .keyword("authorization", self.authorization.clone()) + .keyword("location", self.location.clone()); + + fmt.into_string() + } } diff --git a/src/dialect/base.rs b/src/dialect/base.rs index bc697c1..fbb1385 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -5,7 +5,7 @@ use super::config::*; /// SQL dialect: quoting, literals, and which syntax is legal. /// /// Use a builtin such as `PostgreSql`, or build one from `OPT_*` flags. -#[rapidquery_macros::pyclass("rapidquery.dialect.Dialect")] +#[rapidquery_macros::pyclass("rapidquery.dialect.Dialect", eq, hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[repr(transparent)] pub struct PyDialect(pub DialectConfig); @@ -31,6 +31,9 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyDialect { } } +// FIXME: Set dialect default values as disabled/unsupported when possible, +// otherwise set a traditional one. So developer can predict the values +// without needing to specify all arguments. rapidquery_macros::pydialect_constructor!( placeholder_character: char = '?', @@ -141,6 +144,15 @@ rapidquery_macros::pydialect_constructor!( #[enum_(Unsupported, InfixIdent, FunctionString)] collate_spelling: CollateSpelling = CollateSpelling::default(), + + #[bit(UNSUPPORTED, IF_NOT_EXISTS, EMBEDDED_DDL)] + schema_features: SchemaFeatures = SchemaFeatures::empty().bits(), + + #[enum_(Unsupported, Forced, Optional)] + schema_authorization_style: SchemaAuthorizationStyle = SchemaAuthorizationStyle::default(), + + #[enum_(Unsupported, LocationKeyword, ManagedLocationKeyword, ExternalVolume)] + schema_location_style: SchemaLocationStyle = SchemaLocationStyle::default(), ); #[pyo3::pymethods] @@ -183,17 +195,4 @@ impl PyDialect { self.0.write_bytes(&mut buf, value)?; Ok(buf) } - - fn __eq__(&self, other: &pyo3::Bound<'_, Self>) -> bool { - self.0 == other.get().0 - } - - fn __hash__(&self) -> u64 { - use std::hash::Hash; - use std::hash::Hasher; - - let mut hasher = std::hash::DefaultHasher::default(); - self.0.hash(&mut hasher); - hasher.finish() - } } diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index e2d22e2..a2276c8 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -43,6 +43,9 @@ pub const BIGQUERY_DIALECT: base::PyDialect = base::PyDialect(config::DialectCon uuid_format: config::UuidFormat::Quoted, json_format: config::JsonFormat::JsonKeyword, supports_similar_to: false, + schema_features: config::SchemaFeatures::IF_NOT_EXISTS, + schema_authorization_style: config::SchemaAuthorizationStyle::Unsupported, + schema_location_style: config::SchemaLocationStyle::Unsupported, }); /// Databricks SQL dialect. @@ -87,6 +90,9 @@ pub const DATABRICKS_DIALECT: base::PyDialect = base::PyDialect(config::DialectC uuid_format: config::UuidFormat::Quoted, json_format: config::JsonFormat::Quoted, supports_similar_to: false, + schema_features: config::SchemaFeatures::IF_NOT_EXISTS, + schema_authorization_style: config::SchemaAuthorizationStyle::Unsupported, + schema_location_style: config::SchemaLocationStyle::LocationKeyword, }); /// DuckDB SQL dialect. @@ -131,6 +137,9 @@ pub const DUCKDB_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfi uuid_format: config::UuidFormat::UuidKeyword, json_format: config::JsonFormat::Quoted, supports_similar_to: true, + schema_features: config::SchemaFeatures::IF_NOT_EXISTS, + schema_authorization_style: config::SchemaAuthorizationStyle::Unsupported, + schema_location_style: config::SchemaLocationStyle::Unsupported, }); /// Hive SQL dialect. @@ -175,6 +184,9 @@ pub const HIVE_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfig uuid_format: config::UuidFormat::Quoted, json_format: config::JsonFormat::Quoted, supports_similar_to: false, + schema_features: config::SchemaFeatures::IF_NOT_EXISTS, + schema_authorization_style: config::SchemaAuthorizationStyle::Unsupported, + schema_location_style: config::SchemaLocationStyle::LocationKeyword, }); /// Microsoft SQL Server dialect. @@ -217,6 +229,9 @@ pub const MSSQL_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfig uuid_format: config::UuidFormat::Quoted, json_format: config::JsonFormat::Quoted, supports_similar_to: false, + schema_features: config::SchemaFeatures::EMBEDDED_DDL, + schema_authorization_style: config::SchemaAuthorizationStyle::Optional, + schema_location_style: config::SchemaLocationStyle::Unsupported, }); /// MySQL dialect. @@ -233,6 +248,7 @@ pub const MYSQL_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfig modulo_spelling: config::ModuloSpelling::Percent, is_distinct_from_spelling: config::IsDistinctFromSpelling::NullSafeEqual, cast_try_cast: config::TryCast::Unsupported, + cast_double_colon: false, trim_spelling: config::TrimSpelling::SqlFrom, collate_spelling: config::CollateSpelling::InfixIdent, bitwise_operations: config::BitwiseOperations::AND @@ -260,7 +276,9 @@ pub const MYSQL_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfig uuid_format: config::UuidFormat::Quoted, json_format: config::JsonFormat::Quoted, supports_similar_to: false, - cast_double_colon: false, + schema_features: config::SchemaFeatures::IF_NOT_EXISTS, + schema_authorization_style: config::SchemaAuthorizationStyle::Unsupported, + schema_location_style: config::SchemaLocationStyle::Unsupported, }); /// Oracle SQL dialect. @@ -276,6 +294,7 @@ pub const ORACLE_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfi modulo_spelling: config::ModuloSpelling::Function, is_distinct_from_spelling: config::IsDistinctFromSpelling::Unsupported, cast_try_cast: config::TryCast::Unsupported, + cast_double_colon: false, trim_spelling: config::TrimSpelling::SqlFrom, collate_spelling: config::CollateSpelling::InfixIdent, bitwise_xor_spelling: config::BitwiseXorSpelling::Unsupported, @@ -300,7 +319,9 @@ pub const ORACLE_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfi uuid_format: config::UuidFormat::Quoted, json_format: config::JsonFormat::Quoted, supports_similar_to: false, - cast_double_colon: false, + schema_features: config::SchemaFeatures::EMBEDDED_DDL, + schema_authorization_style: config::SchemaAuthorizationStyle::Forced, + schema_location_style: config::SchemaLocationStyle::Unsupported, }); /// PostgreSQL dialect. @@ -345,6 +366,9 @@ pub const POSTGRESQL_DIALECT: base::PyDialect = base::PyDialect(config::DialectC json_format: config::JsonFormat::Quoted, supports_similar_to: true, cast_double_colon: true, + schema_features: config::SchemaFeatures::IF_NOT_EXISTS.union(config::SchemaFeatures::EMBEDDED_DDL), + schema_authorization_style: config::SchemaAuthorizationStyle::Optional, + schema_location_style: config::SchemaLocationStyle::Unsupported, }); /// Amazon Redshift dialect. @@ -387,6 +411,9 @@ pub const REDSHIFT_DIALECT: base::PyDialect = base::PyDialect(config::DialectCon json_format: config::JsonFormat::Quoted, supports_similar_to: true, cast_double_colon: true, + schema_features: config::SchemaFeatures::IF_NOT_EXISTS.union(config::SchemaFeatures::EMBEDDED_DDL), + schema_authorization_style: config::SchemaAuthorizationStyle::Optional, + schema_location_style: config::SchemaLocationStyle::Unsupported, }); /// Snowflake SQL dialect. @@ -403,6 +430,7 @@ pub const SNOWFLAKE_DIALECT: base::PyDialect = base::PyDialect(config::DialectCo modulo_spelling: config::ModuloSpelling::Percent, is_distinct_from_spelling: config::IsDistinctFromSpelling::Standard, cast_try_cast: config::TryCast::TryCast, + cast_double_colon: true, trim_spelling: config::TrimSpelling::Function, collate_spelling: config::CollateSpelling::FunctionString, bitwise_operations: config::BitwiseOperations::AND @@ -430,7 +458,9 @@ pub const SNOWFLAKE_DIALECT: base::PyDialect = base::PyDialect(config::DialectCo uuid_format: config::UuidFormat::Quoted, json_format: config::JsonFormat::ParseJson, supports_similar_to: false, - cast_double_colon: true, + schema_features: config::SchemaFeatures::IF_NOT_EXISTS, + schema_authorization_style: config::SchemaAuthorizationStyle::Unsupported, + schema_location_style: config::SchemaLocationStyle::ExternalVolume, }); /// Apache Spark SQL dialect. @@ -447,6 +477,7 @@ pub const SPARK_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfig modulo_spelling: config::ModuloSpelling::Percent, is_distinct_from_spelling: config::IsDistinctFromSpelling::Standard, cast_try_cast: config::TryCast::TryCast, + cast_double_colon: true, trim_spelling: config::TrimSpelling::SqlFrom, collate_spelling: config::CollateSpelling::Unsupported, bitwise_operations: config::BitwiseOperations::AND @@ -474,7 +505,9 @@ pub const SPARK_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfig uuid_format: config::UuidFormat::Quoted, json_format: config::JsonFormat::Quoted, supports_similar_to: false, - cast_double_colon: true, + schema_features: config::SchemaFeatures::IF_NOT_EXISTS, + schema_authorization_style: config::SchemaAuthorizationStyle::Unsupported, + schema_location_style: config::SchemaLocationStyle::LocationKeyword, }); /// SQLite dialect. @@ -490,6 +523,7 @@ pub const SQLITE_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfi modulo_spelling: config::ModuloSpelling::Percent, is_distinct_from_spelling: config::IsDistinctFromSpelling::Standard, cast_try_cast: config::TryCast::Unsupported, + cast_double_colon: false, trim_spelling: config::TrimSpelling::Function, collate_spelling: config::CollateSpelling::InfixIdent, bitwise_xor_spelling: config::BitwiseXorSpelling::Unsupported, @@ -518,7 +552,9 @@ pub const SQLITE_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfi uuid_format: config::UuidFormat::Quoted, json_format: config::JsonFormat::Quoted, supports_similar_to: false, - cast_double_colon: false, + schema_features: config::SchemaFeatures::UNSUPPORTED, + schema_authorization_style: config::SchemaAuthorizationStyle::Unsupported, + schema_location_style: config::SchemaLocationStyle::Unsupported, }); /// Teradata SQL dialect. @@ -535,6 +571,7 @@ pub const TERADATA_DIALECT: base::PyDialect = base::PyDialect(config::DialectCon modulo_spelling: config::ModuloSpelling::Infix, is_distinct_from_spelling: config::IsDistinctFromSpelling::Unsupported, cast_try_cast: config::TryCast::TryCastWord, + cast_double_colon: false, trim_spelling: config::TrimSpelling::SqlFrom, collate_spelling: config::CollateSpelling::Unsupported, bitwise_operations: config::BitwiseOperations::AND @@ -562,5 +599,7 @@ pub const TERADATA_DIALECT: base::PyDialect = base::PyDialect(config::DialectCon uuid_format: config::UuidFormat::Quoted, json_format: config::JsonFormat::Quoted, supports_similar_to: false, - cast_double_colon: false, + schema_features: config::SchemaFeatures::empty(), + schema_authorization_style: config::SchemaAuthorizationStyle::Unsupported, + schema_location_style: config::SchemaLocationStyle::Unsupported, }); diff --git a/src/dialect/config.rs b/src/dialect/config.rs index 3bab4ac..3bec7cf 100644 --- a/src/dialect/config.rs +++ b/src/dialect/config.rs @@ -311,6 +311,32 @@ pub struct DialectConfig { /// - FunctionString: `COLLATE(expr, 'body')`. The identifier body is a string literal. #[enum_(Unsupported, #[default] InfixIdent, FunctionString)] pub collate_spelling: CollateSpelling, + + // --- DDL Schema --- + /// Supported independant schema features, or is it even supported? + /// + /// - UNSUPPORTED: Means dialect does not support schema. + /// - IF_NOT_EXISTS: Is dialect support `IF NOT EXISTS` clause? + /// - EMBEDDED_DDL: Is dialect support embedded DDL statements in `CREATE SCHEMA`? + #[bit(UNSUPPORTED=1<<0, IF_NOT_EXISTS=1<<1, EMBEDDED_DDL=1<<2)] + pub schema_features: SchemaFeatures, + + /// `AUTHORIZATION` clause style. How the schema owner is specified. + /// + /// - Unsupported: Means dialect does not support `AUTHORIZATION` for schema. + /// - Forced: Means authorization is forced, and must schema_name = owner_name. + /// - Optional: Means `AUTHORIZATION` clause is supported and it's optional. + #[enum_(Unsupported, Forced, #[default] Optional)] + pub schema_authorization_style: SchemaAuthorizationStyle, + + /// How schema location is expressed. + /// + /// - Unsupported: Means dialect does not support location for schema at all. + /// - LocationKeyword: `LOCATION location` clause. + /// - ManagedLocationKeyword: `MANAGED LOCATION location` clause. + /// - ExternalVolume: `EXTERNAL_VOLUME = location` clause. + #[enum_(Unsupported, #[default] LocationKeyword, ManagedLocationKeyword, ExternalVolume)] + pub schema_location_style: SchemaLocationStyle, } impl DialectConfig { diff --git a/src/expr/base.rs b/src/expr/base.rs index e198f19..c827e5d 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -26,10 +26,7 @@ where /// /// Shorthand for `PyExpr(expr).into_subclasses(py)` #[inline(always)] -pub(super) fn into_subclasses<'py>( - py: pyo3::Python<'py>, - expr: enum_::ExprInner, -) -> pyo3::PyResult> { +pub fn into_subclasses<'py>(py: pyo3::Python<'py>, expr: enum_::ExprInner) -> pyo3::PyResult> { PyExpr(expr).into_subclasses(py) } @@ -576,10 +573,9 @@ impl PyExpr { /// Render this expression as a parameterized statement. fn prepare<'py>( slf: pyo3::PyRef<'py, Self>, - py: pyo3::Python<'py>, dialect: crate::dialect::PyDialect, ) -> pyo3::PyResult<(String, alias::BoundObject<'py>)> { - let (sql, params) = sql::prepare(py, &slf.0, &dialect)?; + let (sql, params) = sql::prepare(slf.py(), &slf.0, &dialect)?; let params = unsafe { utils::iterator_to_tuple( @@ -592,12 +588,8 @@ impl PyExpr { } /// Render this expression as raw SQL (values inlined as literals). - fn to_sql( - slf: pyo3::PyRef<'_, Self>, - py: pyo3::Python<'_>, - dialect: crate::dialect::PyDialect, - ) -> pyo3::PyResult { - sql::write_sql(py, &slf.0, &dialect).map_err(Into::into) + fn to_sql(slf: pyo3::PyRef<'_, Self>, dialect: crate::dialect::PyDialect) -> pyo3::PyResult { + sql::write_sql(slf.py(), &slf.0, &dialect).map_err(Into::into) } fn __repr__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { diff --git a/src/expr/enum_/ident.rs b/src/expr/enum_/ident.rs index 11ab9b4..70888d5 100644 --- a/src/expr/enum_/ident.rs +++ b/src/expr/enum_/ident.rs @@ -59,6 +59,16 @@ impl> From for IdentPart { } } +impl PartialEq<&str> for IdentPart { + fn eq(&self, other: &&str) -> bool { + match self { + Self::Quote(x) => x.as_str() == *other, + Self::Unquote(x) => x.as_str() == *other, + Self::Asterisk => "*" == *other, + } + } +} + impl WriteSql for IdentPart { #[inline] fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { diff --git a/src/internal/repr.rs b/src/internal/repr.rs index 27c74ca..b970ca9 100644 --- a/src/internal/repr.rs +++ b/src/internal/repr.rs @@ -25,6 +25,12 @@ impl WriteRepr for String { } } +impl WriteRepr for arcstr::ArcStr { + fn write_repr(self, buf: &mut String) { + write!(buf, "{self:?}").unwrap(); + } +} + impl WriteRepr for char { fn write_repr(self, buf: &mut String) { write!(buf, "{self:?}").unwrap(); From 89effe3ead26d7600c0cb38aa5fec7df63805d93 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 22 Oct 2026 11:37:25 +0330 Subject: [PATCH 2/3] Support DROP SCHEMA --- Cargo.lock | 60 +++++++++++++ Cargo.toml | 1 + README.md | 4 +- rapidquery/ddl.py | 2 + rapidquery/ddl.pyi | 42 ++++++++- rapidquery/dialect.py | 24 ++++++ rapidquery/dialect.pyi | 33 +++++-- src/ddl/mod.rs | 5 ++ src/ddl/schema.rs | 180 +++++++++++++++++++++++++++++++++------ src/dialect/base.rs | 4 +- src/dialect/builtin.rs | 10 +-- src/dialect/config.rs | 11 +-- src/internal/sql.rs | 14 ++- tests/test_ddl_schema.py | 159 ++++++++++++++++++++++++++++++++++ 14 files changed, 494 insertions(+), 55 deletions(-) create mode 100644 tests/test_ddl_schema.py diff --git a/Cargo.lock b/Cargo.lock index d21ed39..2de4f57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,12 +74,44 @@ dependencies = [ "windows-link", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -369,6 +401,7 @@ dependencies = [ "arcstr", "bitflags", "chrono", + "derive_more", "hashbrown", "ipnetwork", "mac_address", @@ -401,6 +434,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -419,6 +461,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "shlex" version = "2.0.1" @@ -480,6 +528,18 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "uuid" version = "1.28.0" diff --git a/Cargo.toml b/Cargo.toml index 5de6641..5e360ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ name = "rapidquery" arcstr = { version = "1.2" } bitflags = { version = "2.13.2", default-features = false } chrono = { version = "0.4", default-features = false, features = ["clock"] } +derive_more = { version = "2.1.1", default-features = false, features = ["display", "from_str"] } hashbrown = { version = "0.17.1", default-features = false, features = ["default-hasher", "allocator-api2", "inline-more"] } ipnetwork = { version = "0.20", default-features = false } mac_address = { version = "1.1", default-features = false } diff --git a/README.md b/README.md index 8cedefe..9d3ff11 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ ASTs until being merged into default branch. ### DDL -- [ ] CREATE SCHEMA -- [ ] DROP SCHEMA +- [x] CREATE SCHEMA +- [x] DROP SCHEMA - [ ] CREATE TABLE - [ ] ALTER TABLE - [ ] DROP TABLE diff --git a/rapidquery/ddl.py b/rapidquery/ddl.py index cc7e365..fa727f2 100644 --- a/rapidquery/ddl.py +++ b/rapidquery/ddl.py @@ -1,9 +1,11 @@ from ._core.ddl import ( CreateSchema, DataDefinitionLanguage, + DropSchema, ) __all__ = [ "CreateSchema", "DataDefinitionLanguage", + "DropSchema", ] diff --git a/rapidquery/ddl.pyi b/rapidquery/ddl.pyi index e16b5fe..1c48d04 100644 --- a/rapidquery/ddl.pyi +++ b/rapidquery/ddl.pyi @@ -1,5 +1,6 @@ """Data Definition Language""" +import typing from typing import Self, final from rapidquery.dialect import Dialect @@ -26,17 +27,21 @@ class DataDefinitionLanguage: @final class CreateSchema(DataDefinitionLanguage): - """Represents ``CREATE SCHEMA`` statement.""" + """ + Represents ``CREATE SCHEMA`` statement (and can be ``CREATE DATABASE`` statement). + """ def __new__( cls, name: _IdentExtractor, + *, if_not_exists: bool = False, authorization: str | None = None, location: str | None = None, ) -> Self: """ - Construct a new ``CREATE SCHEMA`` statement. + Construct a new ``CREATE SCHEMA`` statement. If the target dialect specified + ``OPT_SCHEMA_FEATURES_IS_DATABASE`` option, it generates ``CREATE DATABASE`` instead. Args: name: schema name e.g. `"analysis"`. Some dialects supports multipart names (like `"public"."analysis"`) @@ -58,3 +63,36 @@ class CreateSchema(DataDefinitionLanguage): def authorization(self) -> str | None: ... @property def location(self) -> str | None: ... + +@final +class DropSchema(DataDefinitionLanguage): + """ + Represents ``DROP SCHEMA`` statement (and can be ``DROP DATABASE`` statement). + """ + + def __new__( + cls, + name: _IdentExtractor, + *, + if_exists: bool = False, + behavior: typing.Literal["CASCADE", "RESTRICT"] | str | None = None, + ) -> Self: + """ + Construct a new ``DROP SCHEMA`` statement. If the target dialect specified + ``OPT_SCHEMA_FEATURES_IS_DATABASE`` option, it generates ``DROP DATABASE`` instead. + + Args: + name: schema name e.g. `"analysis"`. Some dialects supports multipart names (like `"public"."analysis"`) + so there's no limit and it is in your control. + if_exists: Emits ``IF EXISTS`` clause. Means do nothing if the schame doesn't exist. + *If targeted dialect doesn't support this, it will be ignored.* + behavior: Emits ``CASCADE`` or ``RESTRICT`` keywords. + """ + ... + + @property + def name(self) -> Ident: ... + @property + def if_exists(self) -> bool: ... + @property + def behavior(self) -> typing.Literal["CASCADE", "RESTRICT"] | None: ... diff --git a/rapidquery/dialect.py b/rapidquery/dialect.py index 90ae400..693bf36 100644 --- a/rapidquery/dialect.py +++ b/rapidquery/dialect.py @@ -84,6 +84,18 @@ OPT_RLIKE_EXPRESSION_STANDARD, OPT_RLIKE_EXPRESSION_TILDE, OPT_RLIKE_EXPRESSION_UNSUPPORTED, + OPT_SCHEMA_AUTHORIZATION_STYLE_OPTIONAL, + OPT_SCHEMA_AUTHORIZATION_STYLE_UNSUPPORTED, + OPT_SCHEMA_FEATURES_DROP_CASCADE, + OPT_SCHEMA_FEATURES_DROP_RESTRICT, + OPT_SCHEMA_FEATURES_EMBEDDED_DDL, + OPT_SCHEMA_FEATURES_IF_NOT_EXISTS, + OPT_SCHEMA_FEATURES_IS_DATABASE, + OPT_SCHEMA_FEATURES_UNSUPPORTED, + OPT_SCHEMA_LOCATION_STYLE_EXTERNAL_VOLUME, + OPT_SCHEMA_LOCATION_STYLE_LOCATION_KEYWORD, + OPT_SCHEMA_LOCATION_STYLE_MANAGED_LOCATION_KEYWORD, + OPT_SCHEMA_LOCATION_STYLE_UNSUPPORTED, OPT_STRING_CONCAT_CONCAT_FUNCTION, OPT_STRING_CONCAT_PIPES, OPT_STRING_CONCAT_PLUS, @@ -215,6 +227,18 @@ "OPT_RLIKE_EXPRESSION_STANDARD", "OPT_RLIKE_EXPRESSION_TILDE", "OPT_RLIKE_EXPRESSION_UNSUPPORTED", + "OPT_SCHEMA_AUTHORIZATION_STYLE_OPTIONAL", + "OPT_SCHEMA_AUTHORIZATION_STYLE_UNSUPPORTED", + "OPT_SCHEMA_FEATURES_DROP_CASCADE", + "OPT_SCHEMA_FEATURES_DROP_RESTRICT", + "OPT_SCHEMA_FEATURES_EMBEDDED_DDL", + "OPT_SCHEMA_FEATURES_IF_NOT_EXISTS", + "OPT_SCHEMA_FEATURES_IS_DATABASE", + "OPT_SCHEMA_FEATURES_UNSUPPORTED", + "OPT_SCHEMA_LOCATION_STYLE_EXTERNAL_VOLUME", + "OPT_SCHEMA_LOCATION_STYLE_LOCATION_KEYWORD", + "OPT_SCHEMA_LOCATION_STYLE_MANAGED_LOCATION_KEYWORD", + "OPT_SCHEMA_LOCATION_STYLE_UNSUPPORTED", "OPT_STRING_CONCAT_CONCAT_FUNCTION", "OPT_STRING_CONCAT_PIPES", "OPT_STRING_CONCAT_PLUS", diff --git a/rapidquery/dialect.pyi b/rapidquery/dialect.pyi index 55789de..0b0aeef 100644 --- a/rapidquery/dialect.pyi +++ b/rapidquery/dialect.pyi @@ -8,6 +8,10 @@ OPT_ARRAY_FORMAT_CONSTRUCTOR: typing.Final[int] OPT_ARRAY_FORMAT_FUNCTION: typing.Final[int] OPT_ARRAY_FORMAT_LIST: typing.Final[int] OPT_ARRAY_FORMAT_UNSUPPORTED: typing.Final[int] +OPT_BIT_FORMAT_POSTFIX_BIG_B: typing.Final[int] +OPT_BIT_FORMAT_PREFIX_BIG_B: typing.Final[int] +OPT_BIT_FORMAT_PREFIX_SMALL_B: typing.Final[int] +OPT_BIT_FORMAT_UNSUPPORTED: typing.Final[int] OPT_BITWISE_CALL_SYNTAX_BIT_FUNCTION: typing.Final[int] OPT_BITWISE_CALL_SYNTAX_INFIX: typing.Final[int] OPT_BITWISE_CALL_SYNTAX_SHIFT_FUNCTION: typing.Final[int] @@ -21,10 +25,6 @@ OPT_BITWISE_XOR_SPELLING_CARET: typing.Final[int] OPT_BITWISE_XOR_SPELLING_FUNCTION: typing.Final[int] OPT_BITWISE_XOR_SPELLING_HASH: typing.Final[int] OPT_BITWISE_XOR_SPELLING_UNSUPPORTED: typing.Final[int] -OPT_BIT_FORMAT_POSTFIX_BIG_B: typing.Final[int] -OPT_BIT_FORMAT_PREFIX_BIG_B: typing.Final[int] -OPT_BIT_FORMAT_PREFIX_SMALL_B: typing.Final[int] -OPT_BIT_FORMAT_UNSUPPORTED: typing.Final[int] OPT_BOOL_FORMAT_INTEGER: typing.Final[int] OPT_BOOL_FORMAT_KEYWORD: typing.Final[int] OPT_BYTES_FORMAT_HEX_TO_RAW: typing.Final[int] @@ -89,6 +89,18 @@ OPT_RLIKE_EXPRESSION_REGEXP_MATCHES: typing.Final[int] OPT_RLIKE_EXPRESSION_STANDARD: typing.Final[int] OPT_RLIKE_EXPRESSION_TILDE: typing.Final[int] OPT_RLIKE_EXPRESSION_UNSUPPORTED: typing.Final[int] +OPT_SCHEMA_AUTHORIZATION_STYLE_OPTIONAL: typing.Final[int] +OPT_SCHEMA_AUTHORIZATION_STYLE_UNSUPPORTED: typing.Final[int] +OPT_SCHEMA_FEATURES_EMBEDDED_DDL: typing.Final[int] +OPT_SCHEMA_FEATURES_IF_NOT_EXISTS: typing.Final[int] +OPT_SCHEMA_FEATURES_IS_DATABASE: typing.Final[int] +OPT_SCHEMA_FEATURES_DROP_CASCADE: typing.Final[int] +OPT_SCHEMA_FEATURES_DROP_RESTRICT: typing.Final[int] +OPT_SCHEMA_FEATURES_UNSUPPORTED: typing.Final[int] +OPT_SCHEMA_LOCATION_STYLE_EXTERNAL_VOLUME: typing.Final[int] +OPT_SCHEMA_LOCATION_STYLE_LOCATION_KEYWORD: typing.Final[int] +OPT_SCHEMA_LOCATION_STYLE_MANAGED_LOCATION_KEYWORD: typing.Final[int] +OPT_SCHEMA_LOCATION_STYLE_UNSUPPORTED: typing.Final[int] OPT_STRING_CONCAT_CONCAT_FUNCTION: typing.Final[int] OPT_STRING_CONCAT_PIPES: typing.Final[int] OPT_STRING_CONCAT_PLUS: typing.Final[int] @@ -101,11 +113,11 @@ OPT_STRING_ESCAPE_QUOTE: typing.Final[int] OPT_STRING_FORMAT_PREFIX_BIG_E: typing.Final[int] OPT_STRING_FORMAT_PREFIX_BIG_N: typing.Final[int] OPT_STRING_FORMAT_SINGLE_QUOTE: typing.Final[int] +OPT_TIME_FORMAT_KEYWORD: typing.Final[int] +OPT_TIME_FORMAT_QUOTED: typing.Final[int] OPT_TIMESTAMP_FORMAT_QUOTED: typing.Final[int] OPT_TIMESTAMP_FORMAT_TIMESTAMPTZ: typing.Final[int] OPT_TIMESTAMP_FORMAT_WITH_TIMEZONE: typing.Final[int] -OPT_TIME_FORMAT_KEYWORD: typing.Final[int] -OPT_TIME_FORMAT_QUOTED: typing.Final[int] OPT_TRIM_SPELLING_FUNCTION: typing.Final[int] OPT_TRIM_SPELLING_SPACES_ONLY: typing.Final[int] OPT_TRIM_SPELLING_SQL_FROM: typing.Final[int] @@ -161,6 +173,9 @@ class Dialect: is_distinct_from_spelling: int = ..., trim_spelling: int = ..., collate_spelling: int = ..., + schema_features: int = ..., + schema_authorization_style: int = ..., + schema_location_style: int = ..., ) -> typing.Self: """ Build a dialect from flags. @@ -241,6 +256,12 @@ class Dialect: def tuple_syntax(self) -> int: ... @property def uuid_format(self) -> int: ... + @property + def schema_features(self) -> int: ... + @property + def schema_authorization_style(self) -> int: ... + @property + def schema_location_style(self) -> int: ... def write_boolean(self, value: bool) -> str: """ Write `value` as this dialect's boolean literal. diff --git a/src/ddl/mod.rs b/src/ddl/mod.rs index 64c20a4..ebf0425 100644 --- a/src/ddl/mod.rs +++ b/src/ddl/mod.rs @@ -1,7 +1,9 @@ //! Data Definition Language mod schema; +pub use schema::DropSchemaBehavior; pub use schema::PyCreateSchema; +pub use schema::PyDropSchema; /// Base of every DDL statement. Construct a subclass, not this class. #[rapidquery_macros::pyclass("rapidquery.ddl.DataDefinitionLanguage", subclass)] @@ -15,4 +17,7 @@ pub mod ddl_mod { #[pymodule_export] use super::PyCreateSchema; + + #[pymodule_export] + use super::PyDropSchema; } diff --git a/src/ddl/schema.rs b/src/ddl/schema.rs index 14549fc..6f1f719 100644 --- a/src/ddl/schema.rs +++ b/src/ddl/schema.rs @@ -33,8 +33,12 @@ impl sql::WriteSql for PyCreateSchema { .into()); } - // CREATE SCHEMA - out.push_str("CREATE SCHEMA "); + // CREATE {SCHEMA | DATABASE} + if dialect.0.schema_features.contains(config::SchemaFeatures::IS_DATABASE) { + out.push_str("CREATE DATABASE "); + } else { + out.push_str("CREATE SCHEMA "); + } // ... [IF NOT EXISTS] if self.if_not_exists @@ -46,7 +50,7 @@ impl sql::WriteSql for PyCreateSchema { out.push_str("IF NOT EXISTS "); } - // ... [name] AUTHORIZATION [owner_name] + // ... [] [AUTHORIZATION ] match dialect.0.schema_authorization_style { config::SchemaAuthorizationStyle::Unsupported => { if self.authorization.is_some() { @@ -63,32 +67,14 @@ impl sql::WriteSql for PyCreateSchema { self.name.write_sql(dialect, out)?; if let Some(x) = &self.authorization { - out.push_str(" AUTHORIZATION"); + out.push_str(" AUTHORIZATION "); dialect.0.write_identifier(out, x)?; } } - config::SchemaAuthorizationStyle::Forced => { - // If we have authorization, it should be equal to name. - if let Some(x) = &self.authorization { - let parts = self.name.parts(); - - if parts.len() > 1 || parts[0] != x.as_str() { - return Err(rapidquery_macros::pyerr!( - PyRuntimeError, - "AUTHORIZATION in CREATE SCHEMA is required by this dialect and schema \ - name needs to be equal to authorization. omit authorization, or set same name and authorization." - ) - .into()); - } - } - - out.push_str("AUTHORIZATION "); - self.name.write_sql(dialect, out)?; - } } if let Some(x) = &self.location { - // ... (LOCATION | MANAGED LOCATION | EXTERNAL_VOLUME =) location + // ... {LOCATION | MANAGED LOCATION | EXTERNAL_VOLUME =} match dialect.0.schema_location_style { config::SchemaLocationStyle::Unsupported => { return Err(rapidquery_macros::pyerr!( @@ -119,7 +105,7 @@ impl sql::WriteSql for PyCreateSchema { #[pyo3::pymethods] impl PyCreateSchema { #[new] - #[pyo3(signature=(name, if_not_exists=false, authorization=None, location=None))] + #[pyo3(signature=(name, *, if_not_exists=false, authorization=None, location=None))] fn new( name: IdentExtractor, if_not_exists: bool, @@ -171,3 +157,149 @@ impl PyCreateSchema { fmt.into_string() } } + +/// Represents `CASCADE` and `RESTRICT` keywords in `DROP SCHEMA` statement. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, derive_more::FromStr, derive_more::Display)] +pub enum DropSchemaBehavior { + /// Emits `CASCADE` + #[display("CASCADE")] + Cascade, + /// Emits `RESTRICT` + #[display("RESTRICT")] + Restrict, +} + +/// Represent a `DROP SCHEMA` statement. +#[rapidquery_macros::pyclass("rapidquery.ddl.DropSchema", extends=super::PyDataDefinitionLanguage)] +#[derive(Debug, Clone)] +pub struct PyDropSchema { + /// Schema name e.g. `"datasets"` + pub name: Ident, + + /// Emits `IF EXISTS`. + pub if_exists: bool, + + /// `CASCADE` and `RESTRICT` keywords + pub behavior: Option, +} + +impl sql::WriteSql for PyDropSchema { + fn write_sql(&self, dialect: &crate::dialect::PyDialect, out: &mut W) -> sql::PrepareResult<()> { + if dialect.0.schema_features.contains(config::SchemaFeatures::UNSUPPORTED) { + return Err(rapidquery_macros::pyerr!( + PyRuntimeError, + "DROP SCHEMA statement is not supported by this dialect" + ) + .into()); + } + + // DROP {SCHEMA | DATABASE} + if dialect.0.schema_features.contains(config::SchemaFeatures::IS_DATABASE) { + out.push_str("DROP DATABASE "); + } else { + out.push_str("DROP SCHEMA "); + } + + // ... [IF EXISTS] + if self.if_exists + && dialect + .0 + .schema_features + .contains(config::SchemaFeatures::IF_NOT_EXISTS) + { + out.push_str("IF EXISTS "); + } + + // ... + self.name.write_sql(dialect, out)?; + + // ... [{CASCADE | RESTRICT}] + match self.behavior { + None => {} + Some(DropSchemaBehavior::Cascade) => { + if !dialect.0.schema_features.contains(config::SchemaFeatures::DROP_CASCADE) { + return Err(rapidquery_macros::pyerr!( + PyRuntimeError, + "This dialect does not support CASCADE keyword in DROP SCHEMA statement" + ) + .into()); + } + + out.push_str(" CASCADE"); + } + Some(DropSchemaBehavior::Restrict) => { + if !dialect + .0 + .schema_features + .contains(config::SchemaFeatures::DROP_RESTRICT) + { + return Err(rapidquery_macros::pyerr!( + PyRuntimeError, + "This dialect does not support RESTRICT keyword in DROP SCHEMA statement" + ) + .into()); + } + + out.push_str(" RESTRICT"); + } + } + + Ok(()) + } +} + +#[pyo3::pymethods] +impl PyDropSchema { + #[new] + #[pyo3(signature=(name, *, if_exists=false, behavior=None))] + fn new( + name: IdentExtractor, + if_exists: bool, + behavior: Option<&str>, + ) -> pyo3::PyResult> { + use std::str::FromStr; + + let result = Self { + name: name.0, + if_exists, + behavior: match behavior { + None => None, + Some(x) => Some( + DropSchemaBehavior::from_str(x) + .map_err(|_| rapidquery_macros::pyerr!(PyValueError, "invalid value for behavior: {}", x))?, + ), + }, + }; + Ok(pyo3::PyClassInitializer::from(super::PyDataDefinitionLanguage).add_subclass(result)) + } + + #[getter] + fn name<'a>(&self, py: pyo3::Python<'a>) -> pyo3::PyResult> { + crate::expr::base::into_subclasses(py, self.name.clone().into()) + } + + #[getter] + fn if_exists(&self) -> bool { + self.if_exists + } + + #[getter] + fn behavior(&self) -> Option { + self.behavior.map(|x| x.to_string()) + } + + /// Render this expression as raw SQL (values inlined as literals). + fn to_sql(&self, py: pyo3::Python, dialect: crate::dialect::PyDialect) -> pyo3::PyResult { + sql::write_sql(py, self, &dialect).map_err(Into::into) + } + + fn __repr__(&self) -> String { + let mut fmt = repr::ReprFormatter::new_call("CreateSchema"); + + fmt.positional(self.name.python_repr()) + .keyword_true("if_exists", self.if_exists) + .keyword("behavior", self.behavior.map(|x| x.to_string())); + + fmt.into_string() + } +} diff --git a/src/dialect/base.rs b/src/dialect/base.rs index fbb1385..55e509f 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -145,10 +145,10 @@ rapidquery_macros::pydialect_constructor!( #[enum_(Unsupported, InfixIdent, FunctionString)] collate_spelling: CollateSpelling = CollateSpelling::default(), - #[bit(UNSUPPORTED, IF_NOT_EXISTS, EMBEDDED_DDL)] + #[bit(UNSUPPORTED, IF_NOT_EXISTS, EMBEDDED_DDL, IS_DATABASE, DROP_CASCADE, DROP_RESTRICT)] schema_features: SchemaFeatures = SchemaFeatures::empty().bits(), - #[enum_(Unsupported, Forced, Optional)] + #[enum_(Unsupported, Optional)] schema_authorization_style: SchemaAuthorizationStyle = SchemaAuthorizationStyle::default(), #[enum_(Unsupported, LocationKeyword, ManagedLocationKeyword, ExternalVolume)] diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index a2276c8..ee0f112 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -184,7 +184,7 @@ pub const HIVE_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfig uuid_format: config::UuidFormat::Quoted, json_format: config::JsonFormat::Quoted, supports_similar_to: false, - schema_features: config::SchemaFeatures::IF_NOT_EXISTS, + schema_features: config::SchemaFeatures::IF_NOT_EXISTS.union(config::SchemaFeatures::IS_DATABASE), schema_authorization_style: config::SchemaAuthorizationStyle::Unsupported, schema_location_style: config::SchemaLocationStyle::LocationKeyword, }); @@ -276,7 +276,7 @@ pub const MYSQL_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfig uuid_format: config::UuidFormat::Quoted, json_format: config::JsonFormat::Quoted, supports_similar_to: false, - schema_features: config::SchemaFeatures::IF_NOT_EXISTS, + schema_features: config::SchemaFeatures::IF_NOT_EXISTS.union(config::SchemaFeatures::IS_DATABASE), schema_authorization_style: config::SchemaAuthorizationStyle::Unsupported, schema_location_style: config::SchemaLocationStyle::Unsupported, }); @@ -320,7 +320,7 @@ pub const ORACLE_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfi json_format: config::JsonFormat::Quoted, supports_similar_to: false, schema_features: config::SchemaFeatures::EMBEDDED_DDL, - schema_authorization_style: config::SchemaAuthorizationStyle::Forced, + schema_authorization_style: config::SchemaAuthorizationStyle::Unsupported, schema_location_style: config::SchemaLocationStyle::Unsupported, }); @@ -505,7 +505,7 @@ pub const SPARK_DIALECT: base::PyDialect = base::PyDialect(config::DialectConfig uuid_format: config::UuidFormat::Quoted, json_format: config::JsonFormat::Quoted, supports_similar_to: false, - schema_features: config::SchemaFeatures::IF_NOT_EXISTS, + schema_features: config::SchemaFeatures::IF_NOT_EXISTS.union(config::SchemaFeatures::IS_DATABASE), schema_authorization_style: config::SchemaAuthorizationStyle::Unsupported, schema_location_style: config::SchemaLocationStyle::LocationKeyword, }); @@ -599,7 +599,7 @@ pub const TERADATA_DIALECT: base::PyDialect = base::PyDialect(config::DialectCon uuid_format: config::UuidFormat::Quoted, json_format: config::JsonFormat::Quoted, supports_similar_to: false, - schema_features: config::SchemaFeatures::empty(), + schema_features: config::SchemaFeatures::UNSUPPORTED, schema_authorization_style: config::SchemaAuthorizationStyle::Unsupported, schema_location_style: config::SchemaLocationStyle::Unsupported, }); diff --git a/src/dialect/config.rs b/src/dialect/config.rs index 3bec7cf..664ea10 100644 --- a/src/dialect/config.rs +++ b/src/dialect/config.rs @@ -85,8 +85,7 @@ pub struct DialectConfig { /// Which bitwise operators a dialect can render. /// /// A missing flag rejects that operator. AND, OR, NOT, and the shifts share - /// one spelling ([`BitCall`]). XOR uses [`XorSpelling`] and only when - /// [`Self::XOR`] is set. + /// one spelling ([`BitCall`]). XOR uses [`XorSpelling`]. #[bit(AND = 1 << 0, OR = 1 << 1, NOT = 1 << 2, SHL = 1 << 3, SHR = 1 << 4)] pub bitwise_operations: BitwiseOperations, @@ -318,15 +317,17 @@ pub struct DialectConfig { /// - UNSUPPORTED: Means dialect does not support schema. /// - IF_NOT_EXISTS: Is dialect support `IF NOT EXISTS` clause? /// - EMBEDDED_DDL: Is dialect support embedded DDL statements in `CREATE SCHEMA`? - #[bit(UNSUPPORTED=1<<0, IF_NOT_EXISTS=1<<1, EMBEDDED_DDL=1<<2)] + /// - IS_DATABASE: Schema has same meaning as database in this dialect. + /// - DROP_CASCADE: Supports `CASCADE` keyword in `DROP SCHEMA`. + /// - DROP_RESTRICT: Supports `RESTRICT` keyword in `DROP SCHEMA`. + #[bit(UNSUPPORTED=1<<0, IF_NOT_EXISTS=1<<1, EMBEDDED_DDL=1<<2, IS_DATABASE=1<<3, DROP_CASCADE=1<<4, DROP_RESTRICT=1<<5)] pub schema_features: SchemaFeatures, /// `AUTHORIZATION` clause style. How the schema owner is specified. /// /// - Unsupported: Means dialect does not support `AUTHORIZATION` for schema. - /// - Forced: Means authorization is forced, and must schema_name = owner_name. /// - Optional: Means `AUTHORIZATION` clause is supported and it's optional. - #[enum_(Unsupported, Forced, #[default] Optional)] + #[enum_(Unsupported, #[default] Optional)] pub schema_authorization_style: SchemaAuthorizationStyle, /// How schema location is expressed. diff --git a/src/internal/sql.rs b/src/internal/sql.rs index 42ca903..b872c81 100644 --- a/src/internal/sql.rs +++ b/src/internal/sql.rs @@ -345,15 +345,11 @@ impl std::fmt::Write for ParameterizedSql<'_> { /// Render `self` into `out`. The writer chooses literal vs placeholder for values. pub trait WriteSql { - fn write_sql( - &self, - // NOTE FOR ME: - // Why `PyDialect` instead of Rust `Dialect`? - // because we accept custom serializers and custom serializer functions - // need to get dialect objects. - dialect: &PyDialect, - out: &mut W, - ) -> PrepareResult<()>; + // NOTE FOR ME: + // Why `PyDialect` instead of Rust `Dialect`? + // because we accept custom serializers and custom serializer functions + // need to get dialect objects. + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()>; } /// Render `stmt` as raw SQL (values inlined as literals). diff --git a/tests/test_ddl_schema.py b/tests/test_ddl_schema.py new file mode 100644 index 0000000..1206ac0 --- /dev/null +++ b/tests/test_ddl_schema.py @@ -0,0 +1,159 @@ +import pytest + +import rapidquery as rq + + +class TestCreateSchema: + def test_new(self): + stmt = rq.ddl.CreateSchema("name") + assert stmt.name.to_list() == ["name"] + assert stmt.if_not_exists is False + assert stmt.authorization is None + assert stmt.location is None + + stmt = rq.ddl.CreateSchema("name", if_not_exists=True, authorization="auth", location="loc") + assert stmt.name.to_list() == ["name"] + assert stmt.if_not_exists is True + assert stmt.authorization == "auth" + assert stmt.location == "loc" + + def test_unsupported(self): + stmt = rq.ddl.CreateSchema("name") + + with pytest.raises(RuntimeError, match="supported"): + _ = stmt.to_sql(rq.dialect.Sqlite) + + def test_supported(self): + stmt = rq.ddl.CreateSchema("name") + assert stmt.to_sql(rq.dialect.PostgreSql) == 'CREATE SCHEMA "name"' + + def test_is_database(self): + stmt = rq.ddl.CreateSchema("name") + + dl = rq.dialect.Dialect(schema_features=rq.dialect.OPT_SCHEMA_FEATURES_IS_DATABASE) + assert stmt.to_sql(dl) == 'CREATE DATABASE "name"' + + def test_if_not_exists(self): + stmt = rq.ddl.CreateSchema("name", if_not_exists=True) + assert stmt.if_not_exists is True + + assert stmt.to_sql(rq.dialect.PostgreSql) == 'CREATE SCHEMA IF NOT EXISTS "name"' + + # Should ignore IF NOT EXISTS when a dialect doesn't support it + assert stmt.to_sql(rq.dialect.Dialect()) == 'CREATE SCHEMA "name"' + + def test_authorization(self): + stmt = rq.ddl.CreateSchema("name", authorization="user") + assert stmt.authorization == "user" + + # Unsupported + dl = rq.dialect.Dialect(schema_authorization_style=rq.dialect.OPT_SCHEMA_AUTHORIZATION_STYLE_UNSUPPORTED) + with pytest.raises(RuntimeError, match="supported"): + _ = stmt.to_sql(dl) + + # Optional + dl = rq.dialect.Dialect(schema_authorization_style=rq.dialect.OPT_SCHEMA_AUTHORIZATION_STYLE_OPTIONAL) + assert stmt.to_sql(dl) == 'CREATE SCHEMA "name" AUTHORIZATION "user"' + + stmt = rq.ddl.CreateSchema("name", authorization=None) + assert stmt.to_sql(dl) == 'CREATE SCHEMA "name"' + + def test_location(self): + stmt = rq.ddl.CreateSchema("name", location="s3://depts/finance") + assert stmt.location == "s3://depts/finance" + + # Unsupported + dl = rq.dialect.Dialect(schema_location_style=rq.dialect.OPT_SCHEMA_LOCATION_STYLE_UNSUPPORTED) + with pytest.raises(RuntimeError, match="supported"): + _ = stmt.to_sql(dl) + + # Location Keyword + dl = rq.dialect.Dialect( + string_format=rq.dialect.OPT_STRING_FORMAT_SINGLE_QUOTE, + schema_location_style=rq.dialect.OPT_SCHEMA_LOCATION_STYLE_LOCATION_KEYWORD, + ) + assert stmt.to_sql(dl) == "CREATE SCHEMA \"name\" LOCATION 's3://depts/finance'" + + # ManagedLocation Keyword + dl = rq.dialect.Dialect( + string_format=rq.dialect.OPT_STRING_FORMAT_SINGLE_QUOTE, + schema_location_style=rq.dialect.OPT_SCHEMA_LOCATION_STYLE_MANAGED_LOCATION_KEYWORD, + ) + assert stmt.to_sql(dl) == "CREATE SCHEMA \"name\" MANAGED LOCATION 's3://depts/finance'" + + # External Volume + dl = rq.dialect.Dialect( + string_format=rq.dialect.OPT_STRING_FORMAT_SINGLE_QUOTE, + schema_location_style=rq.dialect.OPT_SCHEMA_LOCATION_STYLE_EXTERNAL_VOLUME, + ) + assert stmt.to_sql(dl) == "CREATE SCHEMA \"name\" EXTERNAL_VOLUME = 's3://depts/finance'" + + +class TestDropSchema: + def test_new(self): + stmt = rq.ddl.DropSchema("name") + assert stmt.name.to_list() == ["name"] + assert stmt.if_exists is False + assert stmt.behavior is None + + stmt = rq.ddl.DropSchema("name", if_exists=True, behavior="CASCADE") + assert stmt.name.to_list() == ["name"] + assert stmt.if_exists is True + assert stmt.behavior == "CASCADE" + + stmt = rq.ddl.DropSchema("name", behavior="Restrict") + assert stmt.name.to_list() == ["name"] + assert stmt.if_exists is False + assert stmt.behavior == "RESTRICT" + + with pytest.raises(ValueError): + _ = rq.ddl.DropSchema("name", behavior="invalid") + + def test_unsupported(self): + stmt = rq.ddl.DropSchema("name") + + with pytest.raises(RuntimeError, match="supported"): + _ = stmt.to_sql(rq.dialect.Sqlite) + + def test_supported(self): + stmt = rq.ddl.DropSchema("name") + assert stmt.to_sql(rq.dialect.PostgreSql) == 'DROP SCHEMA "name"' + + def test_if_exists(self): + stmt = rq.ddl.DropSchema("name", if_exists=True) + assert stmt.if_exists is True + + assert stmt.to_sql(rq.dialect.PostgreSql) == 'DROP SCHEMA IF EXISTS "name"' + + # Should ignore IF EXISTS when a dialect doesn't support it + assert stmt.to_sql(rq.dialect.Dialect()) == 'DROP SCHEMA "name"' + + def test_behavior(self): + stmt = rq.ddl.DropSchema("name", behavior="CASCADE") + assert stmt.behavior == "CASCADE" + + # Unsupported + dl = rq.dialect.Dialect() + with pytest.raises(RuntimeError): + _ = stmt.to_sql(dl) + + # CASCADE Supported + dl = rq.dialect.Dialect(schema_features=rq.dialect.OPT_SCHEMA_FEATURES_DROP_CASCADE) + assert stmt.to_sql(dl) == 'DROP SCHEMA "name" CASCADE' + + # CASCADE Unsupported + dl = rq.dialect.Dialect() + with pytest.raises(RuntimeError): + _ = stmt.to_sql(dl) + + # RESTRICT Supported + stmt = rq.ddl.DropSchema("name", behavior="RESTRICT") + assert stmt.behavior == "RESTRICT" + + dl = rq.dialect.Dialect(schema_features=rq.dialect.OPT_SCHEMA_FEATURES_DROP_RESTRICT) + assert stmt.to_sql(dl) == 'DROP SCHEMA "name" RESTRICT' + + # RESTRICT Unsupported + dl = rq.dialect.Dialect() + with pytest.raises(RuntimeError): + _ = stmt.to_sql(dl) From e669769b953154e7c52b2794e61f7610d6cb6710 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 22 Oct 2026 11:42:20 +0330 Subject: [PATCH 3/3] Fix test workflow --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cbae70b..5e9b6cd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,7 +76,7 @@ jobs: run: maturin develop - name: "Run pytest" - run: pytest -v --workers auto + run: pytest -v -n auto env: HYPOTHESIS_PROFILE: slow