Skip to content
Merged
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: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ For focused iteration, run the narrowest relevant pytest target, for example:
pytest tests/unit/src/sqlbuild/compiler/compile/test_main.py
```

To check whether a branch introduces copied or drifting code in Python or Rust, run:

```bash
uv run dupscore clones --since origin/main
```

`make dupscore` reports the whole repository. See `scripts/dupscore/README.md` for options.

## Tests

- Mirror source layout under `tests/<scope>/src/sqlbuild/...`.
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ check-fensu:


dupscore:
uv run dupscore report
uv run dupscore clones


check:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//! CTE definition and leading-WITH assembly shared by SQL-test planning and rendering.

use crate::compiler::_helpers::sql_tests::sql_scan::{comment_end, skip_whitespace};

/// Render one `name AS (body)` definition, closing a trailing line comment first.
pub(crate) fn cte_definition_sql(name: &str, sql: &str) -> String {
let body = sql.trim_end();
let final_line = body.rsplit_once('\n').map_or(body, |(_, line)| line);
let terminator = if final_line.contains("--") { "\n" } else { "" };
format!("{name} AS ({body}{terminator})")
}

/// Append CTEs whose names are not already present, keeping first definitions and order.
pub(crate) fn with_unique_ctes(
mut ctes: Vec<(String, String)>,
extra: impl IntoIterator<Item = (String, String)>,
) -> Vec<(String, String)> {
for (name, sql) in extra {
if !ctes.iter().any(|(existing, _)| *existing == name) {
ctes.push((name, sql));
}
}
ctes
}

/// Prefix CTEs to a query, merging into its own leading WITH clause when present.
pub(crate) fn with_leading_ctes(ctes: &[(String, String)], body: &str) -> String {
if ctes.is_empty() {
return body.to_string();
}
let definitions: String = ctes
.iter()
.map(|(name, sql)| cte_definition_sql(name, sql))
.collect::<Vec<_>>()
.join(", ");
match leading_with_prefix_end(body) {
Some(end) => format!("{}{definitions}, {}", &body[..end], &body[end..]),
None => format!("WITH {definitions} {body}"),
}
}

/// Return the offset just after a leading `WITH` (and optional `RECURSIVE`) keyword.
pub(crate) fn leading_with_prefix_end(sql: &str) -> Option<usize> {
let mut index = skip_leading_ignorable(sql, 0);
index = keyword_end(sql, index, "WITH")?;
index = skip_leading_ignorable(sql, index);
if let Some(recursive_end) = keyword_end(sql, index, "RECURSIVE") {
index = skip_leading_ignorable(sql, recursive_end);
}
Some(index)
}

fn skip_leading_ignorable(sql: &str, mut index: usize) -> usize {
loop {
index = skip_whitespace(sql, index);
match comment_end(sql, index) {
Ok(Some(end)) => index = end,
Ok(None) | Err(_) => return index,
}
}
}

fn keyword_end(sql: &str, start: usize, keyword: &str) -> Option<usize> {
let end = start + keyword.len();
if !sql.get(start..end)?.eq_ignore_ascii_case(keyword) {
return None;
}
if sql
.as_bytes()
.get(end)
.is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
{
return None;
}
Some(end)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ use std::collections::{HashMap, HashSet, VecDeque};

use serde::{Deserialize, Serialize};

use crate::compiler::_helpers::sql_tests::sql_scan::{
self, Unclosed, comment_end, quoted_end, skip_whitespace,
};
use crate::constants::{
DIRECT_DEPENDENCY_PATH_LENGTH, MACRO_TEST_MODE, TABLE_FUNCTION_TEST_MODE, UDF_TEST_MODE,
};
Expand Down Expand Up @@ -442,7 +445,7 @@ fn validate_expected(
Ok(())
}

fn empty_fixture_marker_matches(sql: &str) -> Result<bool, String> {
pub(crate) fn empty_fixture_marker_matches(sql: &str) -> Result<bool, String> {
let mut index = skip_ignorable(sql, 0)?;
let Some(select_end) = consume_keyword(sql, index, "SELECT") else {
return Ok(false);
Expand Down Expand Up @@ -1054,48 +1057,21 @@ fn byte_at(sql: &str, index: usize) -> Option<u8> {
fn char_len(sql: &str, index: usize) -> usize {
sql[index..].chars().next().map_or(1, char::len_utf8)
}
fn skip_whitespace(sql: &str, mut index: usize) -> usize {
while index < sql.len() {
let Some(c) = sql[index..].chars().next() else {
break;
};
if !c.is_whitespace() {
break;
}
index += c.len_utf8();
}
index
}

fn skip_ignorable(sql: &str, mut index: usize) -> Result<usize, String> {
loop {
index = skip_whitespace(sql, index);
if sql[index..].starts_with("--") {
index = sql[index..]
.find('\n')
.map_or(sql.len(), |value| index + value + 1);
} else if sql[index..].starts_with("/*") {
index = sql[index + 2..].find("*/").map_or_else(
|| Err("SQL test contains an unclosed block comment".to_owned()),
|value| Ok(index + 2 + value + 2),
)?;
} else {
return Ok(index);
match comment_end(sql, index).map_err(|error| scan_error_message(error, "SQL test"))? {
Some(end) => index = end,
None => return Ok(index),
}
}
}

fn skip_non_code(sql: &str, index: usize) -> Result<usize, String> {
if sql[index..].starts_with("--") {
return Ok(sql[index..]
.find('\n')
.map_or(sql.len(), |value| index + value + 1));
}
if sql[index..].starts_with("/*") {
return sql[index + 2..]
.find("*/")
.map(|value| index + 2 + value + 2)
.ok_or_else(|| "SQL test contains an unclosed block comment".to_owned());
if let Some(end) =
comment_end(sql, index).map_err(|error| scan_error_message(error, "SQL test"))?
{
return Ok(end);
}
if matches!(byte_at(sql, index), Some(b'\'') | Some(b'"') | Some(b'`')) {
return skip_quote(sql, index, "SQL test");
Expand All @@ -1104,46 +1080,25 @@ fn skip_non_code(sql: &str, index: usize) -> Result<usize, String> {
}

fn skip_quote(sql: &str, start: usize, context: &str) -> Result<usize, String> {
let Some(quote) = byte_at(sql, start) else {
if byte_at(sql, start).is_none() {
return Err(format!("{context} expected a quote"));
};
let mut index = start + 1;
while index < sql.len() {
if byte_at(sql, index) == Some(quote) {
if byte_at(sql, index + 1) == Some(quote) {
index += 2;
} else {
return Ok(index + 1);
}
} else {
index += char_len(sql, index)
}
}
Err(format!("{context} contains an unclosed quoted string"))
quoted_end(sql, start).map_err(|error| scan_error_message(error, context))
}

fn matching_paren(sql: &str, open: usize, context: &str) -> Result<usize, String> {
let mut depth = 0;
let mut index = open;
while index < sql.len() {
let next = skip_non_code(sql, index)?;
if next != index {
index = next;
continue;
}
match byte_at(sql, index) {
Some(b'(') => depth += 1,
Some(b')') => {
depth -= 1;
if depth == 0 {
return Ok(index);
}
}
_ => {}
}
index += char_len(sql, index);
sql_scan::matching_paren(sql, open).map_err(|error| match error {
Unclosed::Parenthesis => scan_error_message(error, context),
_ => scan_error_message(error, "SQL test"),
})
}

fn scan_error_message(error: Unclosed, context: &str) -> String {
match error {
Unclosed::BlockComment => "SQL test contains an unclosed block comment".to_owned(),
Unclosed::Quote => format!("{context} contains an unclosed quoted string"),
Unclosed::Parenthesis => format!("{context} contains an unclosed parenthesis"),
}
Err(format!("{context} contains an unclosed parenthesis"))
}

fn read_string(sql: &str, start: usize) -> Result<(String, usize), String> {
Expand Down
Loading
Loading