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
170 changes: 88 additions & 82 deletions .github/workflows/release-manual.yml
Original file line number Diff line number Diff line change
Expand Up @@ -363,85 +363,91 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

publish-chocolatey:
name: Publish to Chocolatey
needs: [prepare-release, release]
runs-on: windows-latest
if: ${{ !cancelled() && needs.release.result == 'success' }}

steps:
- uses: actions/checkout@v4
with:
ref: v${{ needs.prepare-release.outputs.new_version }}

- name: Download Windows binary from GitHub release
id: download
shell: pwsh
run: |
$version = '${{ needs.prepare-release.outputs.new_version }}'
$url = "https://github.com/TimelordUK/sql-cli/releases/download/v$version/sql-cli-windows-x64.exe"
$target = Join-Path $env:RUNNER_TEMP 'sql-cli-windows-x64.exe'
Write-Host "Fetching $url"
# Retry a few times — the asset may take a few seconds to appear after release create.
$ok = $false
for ($i = 0; $i -lt 10; $i++) {
try {
Invoke-WebRequest -Uri $url -OutFile $target -UseBasicParsing
$ok = $true
break
} catch {
Write-Host "Attempt $($i+1) failed: $($_.Exception.Message). Retrying in 6s..."
Start-Sleep -Seconds 6
}
}
if (-not $ok) { throw "Could not download $url after retries" }
$hash = (Get-FileHash $target -Algorithm SHA256).Hash
Write-Host "SHA256: $hash"
"checksum=$hash" | Out-File -FilePath $env:GITHUB_OUTPUT -Append

- name: Substitute version and checksum into templates
shell: pwsh
run: |
$version = '${{ needs.prepare-release.outputs.new_version }}'
$checksum = '${{ steps.download.outputs.checksum }}'

(Get-Content chocolatey/sql-cli.nuspec.template -Raw) `
-replace '__VERSION__', $version `
| Set-Content chocolatey/sql-cli.nuspec -NoNewline

(Get-Content chocolatey/tools/chocolateyInstall.ps1.template -Raw) `
-replace '__VERSION__', $version `
-replace '__CHECKSUM__', $checksum `
| Set-Content chocolatey/tools/chocolateyInstall.ps1 -NoNewline

Remove-Item chocolatey/sql-cli.nuspec.template
Remove-Item chocolatey/tools/chocolateyInstall.ps1.template

Write-Host '--- nuspec ---'
Get-Content chocolatey/sql-cli.nuspec
Write-Host '--- chocolateyInstall.ps1 ---'
Get-Content chocolatey/tools/chocolateyInstall.ps1

- name: choco pack
shell: pwsh
working-directory: chocolatey
run: choco pack sql-cli.nuspec

- name: choco push
shell: pwsh
working-directory: chocolatey
env:
CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
run: |
$nupkg = Get-ChildItem -Filter "sql-cli.*.nupkg" | Select-Object -First 1
if (-not $nupkg) { throw "No .nupkg produced by choco pack" }
Write-Host "Pushing $($nupkg.Name)"
choco push $nupkg.FullName --source https://push.chocolatey.org/ --api-key "$env:CHOCOLATEY_API_KEY"

- name: Upload .nupkg artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: sql-cli-chocolatey-nupkg
path: chocolatey/*.nupkg
if-no-files-found: warn
# TEMPORARILY DISABLED (2026-06-07): Chocolatey publish is on hold.
# The initial package submission has been pending moderation on chocolatey.org
# for weeks with no response; the version appears locked while in review, so new
# build pushes are rejected. Re-enable this job (uncomment) once the package is
# approved/signed off by Chocolatey moderation.
#
# publish-chocolatey:
# name: Publish to Chocolatey
# needs: [prepare-release, release]
# runs-on: windows-latest
# if: ${{ !cancelled() && needs.release.result == 'success' }}
#
# steps:
# - uses: actions/checkout@v4
# with:
# ref: v${{ needs.prepare-release.outputs.new_version }}
#
# - name: Download Windows binary from GitHub release
# id: download
# shell: pwsh
# run: |
# $version = '${{ needs.prepare-release.outputs.new_version }}'
# $url = "https://github.com/TimelordUK/sql-cli/releases/download/v$version/sql-cli-windows-x64.exe"
# $target = Join-Path $env:RUNNER_TEMP 'sql-cli-windows-x64.exe'
# Write-Host "Fetching $url"
# # Retry a few times — the asset may take a few seconds to appear after release create.
# $ok = $false
# for ($i = 0; $i -lt 10; $i++) {
# try {
# Invoke-WebRequest -Uri $url -OutFile $target -UseBasicParsing
# $ok = $true
# break
# } catch {
# Write-Host "Attempt $($i+1) failed: $($_.Exception.Message). Retrying in 6s..."
# Start-Sleep -Seconds 6
# }
# }
# if (-not $ok) { throw "Could not download $url after retries" }
# $hash = (Get-FileHash $target -Algorithm SHA256).Hash
# Write-Host "SHA256: $hash"
# "checksum=$hash" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
#
# - name: Substitute version and checksum into templates
# shell: pwsh
# run: |
# $version = '${{ needs.prepare-release.outputs.new_version }}'
# $checksum = '${{ steps.download.outputs.checksum }}'
#
# (Get-Content chocolatey/sql-cli.nuspec.template -Raw) `
# -replace '__VERSION__', $version `
# | Set-Content chocolatey/sql-cli.nuspec -NoNewline
#
# (Get-Content chocolatey/tools/chocolateyInstall.ps1.template -Raw) `
# -replace '__VERSION__', $version `
# -replace '__CHECKSUM__', $checksum `
# | Set-Content chocolatey/tools/chocolateyInstall.ps1 -NoNewline
#
# Remove-Item chocolatey/sql-cli.nuspec.template
# Remove-Item chocolatey/tools/chocolateyInstall.ps1.template
#
# Write-Host '--- nuspec ---'
# Get-Content chocolatey/sql-cli.nuspec
# Write-Host '--- chocolateyInstall.ps1 ---'
# Get-Content chocolatey/tools/chocolateyInstall.ps1
#
# - name: choco pack
# shell: pwsh
# working-directory: chocolatey
# run: choco pack sql-cli.nuspec
#
# - name: choco push
# shell: pwsh
# working-directory: chocolatey
# env:
# CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
# run: |
# $nupkg = Get-ChildItem -Filter "sql-cli.*.nupkg" | Select-Object -First 1
# if (-not $nupkg) { throw "No .nupkg produced by choco pack" }
# Write-Host "Pushing $($nupkg.Name)"
# choco push $nupkg.FullName --source https://push.chocolatey.org/ --api-key "$env:CHOCOLATEY_API_KEY"
#
# - name: Upload .nupkg artifact
# if: always()
# uses: actions/upload-artifact@v4
# with:
# name: sql-cli-chocolatey-nupkg
# path: chocolatey/*.nupkg
# if-no-files-found: warn
15 changes: 12 additions & 3 deletions src/data/datatable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,18 @@ impl DataValue {

match data_type {
DataType::String => DataValue::String(s.to_string()),
DataType::Integer => s
.parse::<i64>()
.map_or_else(|_| DataValue::String(s.to_string()), DataValue::Integer),
DataType::Integer => s.parse::<i64>().map_or_else(
// The column was inferred as Integer (type inference only samples
// the first N rows, so a fractional value further down can be
// missed). Promote to Float rather than demoting to String, which
// would corrupt numeric sorting (String sorts after all numbers).
// The final infer_column_types() pass re-merges the column to Float.
|_| {
s.parse::<f64>()
.map_or_else(|_| DataValue::String(s.to_string()), DataValue::Float)
},
DataValue::Integer,
),
DataType::Float => s
.parse::<f64>()
.map_or_else(|_| DataValue::String(s.to_string()), DataValue::Float),
Expand Down
30 changes: 30 additions & 0 deletions src/data/datatable_loaders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,36 @@ mod tests {
Ok(())
}

#[test]
fn test_fractional_value_beyond_sample_window_promotes_to_float() -> Result<()> {
// Regression: type inference only samples the first 100 rows. A column
// that is all integers in the sample but has a fractional value further
// down used to demote that value to a String, which then sorted after
// every numeric value (String > Integer). It must be a Float instead.
let mut temp_file = NamedTempFile::new()?;
writeln!(temp_file, "id,area")?;
for i in 0..120 {
writeln!(temp_file, "{i},{}", i * 10)?; // all integers in the sample
}
writeln!(temp_file, "999,34.2")?; // fractional value past row 100
temp_file.flush()?;

let table = load_csv_to_datatable(temp_file.path(), "areas")?;

// The column re-merges to Float once the fractional value is seen.
let area_idx = table.get_column_index("area").unwrap();
assert_eq!(table.columns[area_idx].data_type, DataType::Float);

// The fractional value is stored as a number, not a String.
let last = table.get_value(120, area_idx).unwrap();
assert!(
matches!(last, DataValue::Float(f) if (*f - 34.2).abs() < 1e-9),
"expected Float(34.2), got {last:?}"
);

Ok(())
}

#[test]
fn test_load_json() -> Result<()> {
// Create a temporary JSON file
Expand Down
119 changes: 119 additions & 0 deletions src/query_plan/where_alias_expander.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,70 @@ impl WhereAliasExpander {
)
}

// Expand in method calls, e.g. `alias.Contains('x')`.
// The receiver is a bare column-name string, so an alias can only be
// substituted if it resolves to a simple (un-prefixed) column.
SqlExpression::MethodCall {
object,
method,
args,
} => {
let mut expanded = false;
let new_args: Vec<SqlExpression> = args
.iter()
.map(|arg| {
let (new_arg, arg_expanded) = Self::expand_expression(arg, aliases);
expanded = expanded || arg_expanded;
new_arg
})
.collect();

let mut new_object = object.clone();
if let Some(SqlExpression::Column(col_ref)) = aliases.get(object) {
if col_ref.table_prefix.is_none() {
debug!(
"Expanding alias '{}' in WHERE method call to column '{}'",
object, col_ref.name
);
new_object = col_ref.name.clone();
expanded = true;
}
}

(
SqlExpression::MethodCall {
object: new_object,
method: method.clone(),
args: new_args,
},
expanded,
)
}

// Expand in chained method calls, e.g. `(alias).Trim().Contains('x')`.
// The base is itself an expression, so recurse into it normally.
SqlExpression::ChainedMethodCall { base, method, args } => {
let (new_base, base_expanded) = Self::expand_expression(base, aliases);
let mut expanded = base_expanded;
let new_args: Vec<SqlExpression> = args
.iter()
.map(|arg| {
let (new_arg, arg_expanded) = Self::expand_expression(arg, aliases);
expanded = expanded || arg_expanded;
new_arg
})
.collect();

(
SqlExpression::ChainedMethodCall {
base: Box::new(new_base),
method: method.clone(),
args: new_args,
},
expanded,
)
}

// For all other expressions, return as-is
_ => (expr.clone(), false),
}
Expand Down Expand Up @@ -506,6 +570,61 @@ mod tests {
assert_eq!(transformer.expansions, 1);
}

#[test]
fn test_expand_alias_in_method_call_receiver() {
// `SELECT "name.common" as name ... WHERE name.Contains('x')`
// The alias `name` resolves to the column `name.common`, so the method
// call's receiver should be rewritten to that column name.
let aliases = HashMap::from([(
"name".to_string(),
SqlExpression::Column(ColumnRef {
name: "name.common".to_string(),
quote_style: QuoteStyle::DoubleQuotes,
table_prefix: None,
}),
)]);

let expr = SqlExpression::MethodCall {
object: "name".to_string(),
method: "Contains".to_string(),
args: vec![SqlExpression::StringLiteral("united".to_string())],
};

let (expanded, changed) = WhereAliasExpander::expand_expression(&expr, &aliases);

assert!(changed);
match expanded {
SqlExpression::MethodCall { object, method, .. } => {
assert_eq!(object, "name.common");
assert_eq!(method, "Contains");
}
other => panic!("Expected MethodCall, got {other:?}"),
}
}

#[test]
fn test_does_not_expand_method_call_for_nonalias() {
// A method call whose receiver is a real column (not an alias) is untouched.
let aliases = HashMap::from([(
"name".to_string(),
SqlExpression::Column(ColumnRef::unquoted("name.common".to_string())),
)]);

let expr = SqlExpression::MethodCall {
object: "capital".to_string(),
method: "Contains".to_string(),
args: vec![SqlExpression::StringLiteral("x".to_string())],
};

let (expanded, changed) = WhereAliasExpander::expand_expression(&expr, &aliases);

assert!(!changed);
assert!(matches!(
expanded,
SqlExpression::MethodCall { object, .. } if object == "capital"
));
}

#[test]
fn test_does_not_expand_table_prefixed_columns() {
let aliases = HashMap::from([(
Expand Down
Loading