diff --git a/.github/workflows/release-manual.yml b/.github/workflows/release-manual.yml index fbde0fc4..1f74a311 100644 --- a/.github/workflows/release-manual.yml +++ b/.github/workflows/release-manual.yml @@ -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 \ No newline at end of file +# 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 \ No newline at end of file diff --git a/src/data/datatable.rs b/src/data/datatable.rs index 4d6c1cd4..0bcc921b 100644 --- a/src/data/datatable.rs +++ b/src/data/datatable.rs @@ -310,9 +310,18 @@ impl DataValue { match data_type { DataType::String => DataValue::String(s.to_string()), - DataType::Integer => s - .parse::() - .map_or_else(|_| DataValue::String(s.to_string()), DataValue::Integer), + DataType::Integer => s.parse::().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::() + .map_or_else(|_| DataValue::String(s.to_string()), DataValue::Float) + }, + DataValue::Integer, + ), DataType::Float => s .parse::() .map_or_else(|_| DataValue::String(s.to_string()), DataValue::Float), diff --git a/src/data/datatable_loaders.rs b/src/data/datatable_loaders.rs index 1645a8b5..27425e1e 100644 --- a/src/data/datatable_loaders.rs +++ b/src/data/datatable_loaders.rs @@ -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 diff --git a/src/query_plan/where_alias_expander.rs b/src/query_plan/where_alias_expander.rs index c06e1b37..09b92880 100644 --- a/src/query_plan/where_alias_expander.rs +++ b/src/query_plan/where_alias_expander.rs @@ -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 = 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 = 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), } @@ -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([(