Skip to content

Commit fb0b30a

Browse files
committed
[SEA-NodeJS] Fix DECIMAL (p,s) derivation — reject overflow/non-numeric, drop leading-zero miscount
Code-review #412 (gopalldb P1). `decimalPrecisionScale` derived precision by counting digit characters, which (a) counted insignificant leading zeros ("0.00001" → DECIMAL(6,5)) and (b) clamped precision to 38 while still sending the full value — so a 40-digit value produced DECIMAL(38,0) alongside an unrepresentable 40-digit value, which the kernel rejects or silently truncates. The prior test blessed that clamp as correct (wrong contract). Now: - Precision = significant integer digits (insignificant leading zeros stripped, matching Spark's decimal-literal rule) + fractional scale. "0.00001" → DECIMAL(5,5); "007.50" → DECIMAL(3,2); "0" → DECIMAL(1,0). - A value needing precision > 38 throws ParameterError at bind time instead of clamp-and-send (P1.1). - A non-numeric / exponential value ("1e+21", "abc", "") throws ParameterError with a clear message rather than mis-deriving (p,s) and surfacing an opaque kernel error (P1.2 / P2). Validated live: 0.00001 binds as DECIMAL(5,5) and round-trips; a 40-digit value is rejected client-side with ParameterError (no server round-trip). Unit tests updated to assert the new reject contract + leading-zero handling. Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
1 parent 5cc943a commit fb0b30a

2 files changed

Lines changed: 62 additions & 22 deletions

File tree

lib/sea/SeaPositionalParams.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,47 @@
1313
// limitations under the License.
1414

1515
import { DBSQLParameter, DBSQLParameterValue } from '../DBSQLParameter';
16+
import ParameterError from '../errors/ParameterError';
1617
import { SeaNativeTypedValueInput, SeaNativeNamedTypedValueInput } from './SeaNativeLoader';
1718
import assertBindableValue from './SeaInputValidation';
1819

1920
/**
2021
* Derive `(precision,scale)` from a decimal value string for the SEA
21-
* `DECIMAL(p,s)` type name — the kernel param codec requires the
22-
* parenthesised form (plain `"DECIMAL"` is rejected) so it can preserve
23-
* the caller's fractional digits. `"99.99"` ⇒ `"4,2"`; `"-123"` ⇒ `"3,0"`.
24-
* Clamped to the Databricks max precision of 38.
22+
* `DECIMAL(p,s)` type name — the kernel param codec requires the parenthesised
23+
* form (plain `"DECIMAL"` is rejected) so it preserves the caller's fractional
24+
* digits. `"99.99"` ⇒ `"4,2"`; `"-123"` ⇒ `"3,0"`; `"0.00001"` ⇒ `"5,5"`.
25+
*
26+
* Precision is significant integer digits (insignificant leading zeros
27+
* stripped, matching Spark's decimal-literal rule) plus the fractional scale.
28+
* The value is NOT clamped: a non-numeric value, or one that needs precision >
29+
* the Databricks maximum of 38, throws `ParameterError` at bind time rather
30+
* than emitting an inconsistent `DECIMAL(38,…)` type alongside an
31+
* unrepresentable value (which the kernel would reject or silently truncate).
2532
*/
2633
function decimalPrecisionScale(v: string): string {
27-
const digits = (v.match(/\d/g) ?? []).length;
28-
const dot = v.indexOf('.');
29-
const scale = dot < 0 ? 0 : (v.slice(dot + 1).match(/\d/g) ?? []).length;
30-
const precision = Math.min(Math.max(digits, 1), 38);
31-
return `${precision},${Math.min(scale, precision)}`;
34+
// Accept an optional sign + plain decimal numeral only. Exponential form
35+
// ("1e+21"), empty, and non-numeric ("abc") are rejected with a clear error
36+
// instead of mis-deriving (p,s) and surfacing an opaque kernel failure.
37+
const m = /^[+-]?(\d*)(?:\.(\d+))?$/.exec(v.trim());
38+
const intPart = m?.[1] ?? '';
39+
const fracPart = m?.[2] ?? '';
40+
if (!m || (intPart === '' && fracPart === '')) {
41+
throw new ParameterError(
42+
`DECIMAL parameter value "${v}" is not a plain decimal numeral (expected [+-]?digits[.digits]).`,
43+
);
44+
}
45+
const scale = fracPart.length;
46+
// Significant integer digits: drop insignificant leading zeros ("007" → 1,
47+
// "0" / "" → 0) so e.g. 0.00001 is DECIMAL(5,5), not DECIMAL(6,5).
48+
const significantIntDigits = intPart.replace(/^0+/, '').length;
49+
const precision = Math.max(significantIntDigits + scale, 1);
50+
if (precision > 38) {
51+
throw new ParameterError(
52+
`DECIMAL parameter value "${v}" needs precision ${precision}, exceeding the Databricks maximum of 38. ` +
53+
'Round/scale the value or bind it as a STRING.',
54+
);
55+
}
56+
return `${precision},${scale}`;
3257
}
3358

3459
/**

tests/unit/sea/positionalParams.test.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import { expect } from 'chai';
1616
import { buildSeaPositionalParams, buildSeaNamedParams } from '../../../lib/sea/SeaPositionalParams';
1717
import { DBSQLParameter, DBSQLParameterType } from '../../../lib/DBSQLParameter';
18+
import ParameterError from '../../../lib/errors/ParameterError';
1819

1920
describe('SeaPositionalParams.buildSeaPositionalParams', () => {
2021
it('returns undefined for no params (keeps the no-options fast path)', () => {
@@ -30,22 +31,36 @@ describe('SeaPositionalParams.buildSeaPositionalParams', () => {
3031
]);
3132
});
3233

34+
const decimal = (value: string) => () =>
35+
buildSeaPositionalParams([new DBSQLParameter({ type: DBSQLParameterType.DECIMAL, value })]);
36+
3337
it('emits DECIMAL in the parenthesised DECIMAL(p,s) form the kernel codec requires', () => {
34-
expect(
35-
buildSeaPositionalParams([new DBSQLParameter({ type: DBSQLParameterType.DECIMAL, value: '99.99' })]),
36-
).to.deep.equal([{ sqlType: 'DECIMAL(4,2)', value: '99.99' }]);
37-
expect(
38-
buildSeaPositionalParams([new DBSQLParameter({ type: DBSQLParameterType.DECIMAL, value: '-123' })]),
39-
).to.deep.equal([{ sqlType: 'DECIMAL(3,0)', value: '-123' }]);
38+
expect(decimal('99.99')()).to.deep.equal([{ sqlType: 'DECIMAL(4,2)', value: '99.99' }]);
39+
expect(decimal('-123')()).to.deep.equal([{ sqlType: 'DECIMAL(3,0)', value: '-123' }]);
4040
});
4141

42-
it('clamps DECIMAL precision to the Databricks max of 38', () => {
43-
// 40 integer digits → precision clamped to 38 (scale 0).
44-
expect(
45-
buildSeaPositionalParams([
46-
new DBSQLParameter({ type: DBSQLParameterType.DECIMAL, value: '1234567890123456789012345678901234567890' }),
47-
]),
48-
).to.deep.equal([{ sqlType: 'DECIMAL(38,0)', value: '1234567890123456789012345678901234567890' }]);
42+
it('excludes insignificant leading zeros from precision (Spark decimal-literal rule)', () => {
43+
// 0.00001 → DECIMAL(5,5), not DECIMAL(6,5) (the integer "0" is not significant).
44+
expect(decimal('0.00001')()).to.deep.equal([{ sqlType: 'DECIMAL(5,5)', value: '0.00001' }]);
45+
// "007.50" → significant int "7" (1) + scale 2 ⇒ DECIMAL(3,2).
46+
expect(decimal('007.50')()).to.deep.equal([{ sqlType: 'DECIMAL(3,2)', value: '007.50' }]);
47+
// "0" ⇒ DECIMAL(1,0) (minimum precision 1).
48+
expect(decimal('0')()).to.deep.equal([{ sqlType: 'DECIMAL(1,0)', value: '0' }]);
49+
});
50+
51+
it('rejects a DECIMAL that needs precision > 38 instead of clamping-and-sending', () => {
52+
// 40 integer digits can't fit DECIMAL(38,…); clamping the type while sending
53+
// the full value is internally inconsistent — reject at bind time instead.
54+
expect(decimal('1234567890123456789012345678901234567890')).to.throw(
55+
ParameterError,
56+
/exceeding the Databricks maximum of 38/,
57+
);
58+
});
59+
60+
it('rejects a non-numeric / exponential DECIMAL value', () => {
61+
expect(decimal('1e+21')).to.throw(ParameterError, /not a plain decimal numeral/);
62+
expect(decimal('abc')).to.throw(ParameterError, /not a plain decimal numeral/);
63+
expect(decimal('')).to.throw(ParameterError, /not a plain decimal numeral/);
4964
});
5065

5166
it('collapses every INTERVAL subtype to the kernel codec\'s single "INTERVAL" type name', () => {

0 commit comments

Comments
 (0)