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
45 changes: 0 additions & 45 deletions .github/workflows/e2e-install.yml

This file was deleted.

91 changes: 90 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ name: 'publish'
# When triggered manually, it will publish with the "latest" tag,
# and when triggered on push to `release`, it will publish with the "head" tag.
# The `main` branch is reserved for development and does not publish.
# For both it uses NPM OIDC authentication with provenance support
# For both it uses NPM OIDC authentication with provenance support.
#
# After a successful publish, the `e2e` job waits for the freshly published
# version to become available on the npm registry, installs that exact
# version into a tiny downstream project, and verifies it is usable.

permissions:
contents: read
Expand Down Expand Up @@ -40,6 +44,8 @@ jobs:
head:
if: github.ref == 'refs/heads/release' && github.event_name == 'push'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand All @@ -54,11 +60,13 @@ jobs:
run: npm ci

- name: Set head pre-release version
id: version
run: |
BASE_VERSION=$(node -p "require('./packages/client-common/package.json').version")
HEAD_VERSION="${BASE_VERSION}-head.${GITHUB_SHA::7}.${GITHUB_RUN_ATTEMPT}"
echo "Setting version to: $HEAD_VERSION"
.scripts/update_version.sh "$HEAD_VERSION"
echo "version=$HEAD_VERSION" >> "$GITHUB_OUTPUT"

- name: Build packages
run: npm --workspaces run build
Expand All @@ -76,6 +84,8 @@ jobs:
permissions:
contents: write # Required to push the release git tag
id-token: write # Required for npm OIDC authentication and provenance
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down Expand Up @@ -117,3 +127,82 @@ jobs:
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "${RELEASE_VERSION}" -m "Release ${RELEASE_VERSION}"
git push origin "refs/tags/${RELEASE_VERSION}"

e2e:
name: e2e (node ${{ matrix.node }})
needs: [head, latest]
# Run when at least one of the publish jobs succeeded. The other one is
# skipped (not failed) by its `if:` condition for this trigger.
if: |
always() &&
(needs.head.result == 'success' || needs.latest.result == 'success')
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
node: [20, 22, 24]
defaults:
run:
working-directory: tests/e2e/install
env:
PUBLISHED_VERSION: ${{ needs.head.outputs.version || needs.latest.outputs.version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup NodeJS ${{ matrix.node }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ matrix.node }}
registry-url: 'https://registry.npmjs.org'

- name: Wait for ${{ needs.head.outputs.version || needs.latest.outputs.version }} to be available on npm
run: |
set -euo pipefail
if [ -z "${PUBLISHED_VERSION}" ]; then
echo "PUBLISHED_VERSION is empty; cannot wait for npm publication." >&2
exit 1
fi
packages=(
"@clickhouse/client-common"
"@clickhouse/client"
"@clickhouse/client-web"
)
# Poll the registry for up to ~5 minutes per package. New versions
# usually surface in seconds, but the registry CDN can lag.
max_attempts=60
sleep_seconds=5
for pkg in "${packages[@]}"; do
echo "Waiting for ${pkg}@${PUBLISHED_VERSION} to be available on npm..."
attempt=1
while true; do
if npm view "${pkg}@${PUBLISHED_VERSION}" version >/dev/null 2>&1; then
echo " ${pkg}@${PUBLISHED_VERSION} is available."
break
fi
if [ "$attempt" -ge "$max_attempts" ]; then
echo "Timed out waiting for ${pkg}@${PUBLISHED_VERSION} on npm" >&2
exit 1
fi
echo " attempt ${attempt}/${max_attempts}: not available yet, sleeping ${sleep_seconds}s..."
attempt=$((attempt + 1))
sleep "$sleep_seconds"
done
done

- name: Install dependencies
run: npm install

- name: Install the packages at the published version
run: |
npm install \
"@clickhouse/client@${PUBLISHED_VERSION}" \
"@clickhouse/client-common@${PUBLISHED_VERSION}" \
"@clickhouse/client-web@${PUBLISHED_VERSION}"

- name: Type check
run: npx tsc --noEmit

- name: Run client code
env:
EXPECTED_VERSION: ${{ env.PUBLISHED_VERSION }}
run: node src/index.ts
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
# 1.19.0

## Breaking Changes

- **Enum type parsing now correctly unescapes backslash escape sequences in enum names.** Previously, `parseEnumType` returned enum names with raw escape sequences (e.g., `f\'` instead of `f'`). Now it properly decodes escape sequences including `\'` (single quote), `\\` (backslash), `\n` (newline), `\t` (tab), and `\r` (carriage return). This matches the behavior of ClickHouse string literals and ensures consistency with how the client encodes strings when sending data to the server. If you were relying on the previous incorrect behavior where backslash escape sequences were preserved in enum names, you will need to update your code to handle properly unescaped values.

Example:

```ts
// Before (incorrect):
parseEnumType({
columnType: "Enum8('f\\'' = 1)",
sourceType: "Enum8('f\\'' = 1)",
})
// returned: { values: { 1: "f\\'" } } // with backslash

// After (correct):
parseEnumType({
columnType: "Enum8('f\\'' = 1)",
sourceType: "Enum8('f\\'' = 1)",
})
// returns: { values: { 1: "f'" } } // unescaped
```

# 1.18.5

## Improvements
Expand Down
11 changes: 6 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,33 @@ describe('Columns types parser - Enum', () => {
}),
)
})

it('should unescape backslash escape sequences in enum names', async () => {
// Test case from issue: parseEnumType returns escaped backslashes instead of unescaping them
const result = parseEnumType({
columnType: "Enum8('f\\'' = 1)",
sourceType: "Enum8('f\\'' = 1)",
})
expect(result).toEqual({
type: 'Enum',
values: { 1: "f'" }, // Should be unescaped, not "f\\'"
intSize: 8,
sourceType: "Enum8('f\\'' = 1)",
})

// Test various escape sequences
const testCases: Array<[string, string]> = [
["Enum8('\\n' = 1)", '\n'], // newline
["Enum8('\\t' = 1)", '\t'], // tab
["Enum8('\\r' = 1)", '\r'], // carriage return
["Enum8('\\\\' = 1)", '\\'], // backslash
["Enum8('a\\nb' = 1)", 'a\nb'], // newline in middle
["Enum8('\\t\\n\\r' = 1)", '\t\n\r'], // multiple escapes
]

testCases.forEach(([sourceType, expectedName]) => {
const parsed = parseEnumType({ columnType: sourceType, sourceType })
expect(parsed.values[1]).toBe(expectedName)
})
})
})
10 changes: 5 additions & 5 deletions packages/client-common/__tests__/utils/native_columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ export const parsedEnumTestArgs: ParsedColumnEnum[] = enumTypes.flatMap(
type: 'Enum',
sourceType: `${enumType}('f\\'' = 1, 'x =' = 2, 'b\\'\\'\\'' = 3, '\\'c=4=' = 42, '4' = 100)`,
values: {
1: "f\\'",
1: "f'",
2: 'x =',
3: "b\\'\\'\\'",
42: "\\'c=4=",
3: "b'''",
42: "'c=4=",
100: '4',
},
intSize,
Expand All @@ -50,15 +50,15 @@ export const parsedEnumTestArgs: ParsedColumnEnum[] = enumTypes.flatMap(
type: 'Enum',
sourceType: `${enumType}('f\\'()' = 1)`,
values: {
1: "f\\'()",
1: "f'()",
},
intSize,
},
{
type: 'Enum',
sourceType: `${enumType}('\\'' = 0)`,
values: {
0: `\\'`,
0: `'`,
},
intSize,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/client-common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@clickhouse/client-common",
"description": "Official JS client for ClickHouse DB - common types",
"homepage": "https://clickhouse.com",
"version": "1.18.5",
"version": "1.19.0",
"license": "Apache-2.0",
"keywords": [
"clickhouse",
Expand Down
48 changes: 47 additions & 1 deletion packages/client-common/src/parse/column_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,50 @@ export function parseDecimalType({
}
}

/**
* Unescape backslash escape sequences in enum names.
* Recognized escapes are decoded using ClickHouse-style string escaping:
* `\\n` -> newline, `\\t` -> tab, `\\r` -> carriage return, `\\\\` -> `\\`,
* and `\\'` -> `'`.
* For any other escaped character, the backslash is removed and the following
* character is kept verbatim to preserve the previous permissive behavior.
*/
function unescapeEnumName(escaped: string): string {
let unescaped = ''
let i = 0
while (i < escaped.length) {
if (escaped.charCodeAt(i) === BackslashASCII && i + 1 < escaped.length) {
i++
switch (escaped[i]) {
case 'n':
unescaped += '\n'
break
case 't':
unescaped += '\t'
break
case 'r':
unescaped += '\r'
break
case '\\':
unescaped += '\\'
break
case "'":
unescaped += "'"
break
default:
// Preserve previous behavior for unknown escape sequences by
// dropping the backslash and keeping the escaped character.
unescaped += escaped[i]
break
}
} else {
unescaped += escaped[i]
}
i++
}
return unescaped
}

export function parseEnumType({
columnType,
sourceType,
Expand Down Expand Up @@ -327,7 +371,9 @@ export function parseEnumType({
charEscaped = true
} else if (columnType.charCodeAt(i) === SingleQuoteASCII) {
// non-escaped closing tick - push the name
const name = columnType.slice(startIndex, i)
const rawName = columnType.slice(startIndex, i)
// Unescape the name by removing backslash escape sequences
const name = unescapeEnumName(rawName)
if (names.includes(name)) {
throw new ColumnTypeParseError('Duplicate Enum name', {
columnType,
Expand Down
2 changes: 1 addition & 1 deletion packages/client-common/src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export default '1.18.5'
export default '1.19.0'
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe('getAsText', () => {
it('should flush the decoder at the end of the stream', async () => {
const stream = makeStreamFromBuffers([
Buffer.from([0x61, 0x20, 0xe2, 0x82]), // first 2 bytes of '€'
// no more bytes, but the decoder should be flushed and return the butes it has buffered
// no more bytes, but the decoder should be flushed and return the bytes it has buffered
])
const text = 'a \ufffd'
expect(await getAsText(stream)).toBe(text)
Expand Down
Loading
Loading