@@ -182,6 +182,82 @@ const timeFormatSchema = Joi.alternatives([
182182 customTimeFormatSchema
183183] ) ;
184184
185+ // d3-format specification (Python format spec mini-language)
186+ // See: https://d3js.org/d3-format
187+ // See: https://docs.python.org/3/library/string.html#format-specification-mini-language
188+ // Format specifier: [[fill]align][sign][symbol][0][width][,][.precision][~][type]
189+ const NUMERIC_FORMAT_TYPES = new Set ( [
190+ 'e' , // exponent notation
191+ 'f' , // fixed-point notation
192+ 'g' , // either decimal or exponent notation
193+ 'r' , // decimal notation, rounded to significant digits
194+ 's' , // decimal notation with an SI prefix
195+ '%' , // multiply by 100 and format as percentage
196+ 'p' , // multiply by 100, round to significant digits, and format as percentage
197+ 'b' , // binary notation
198+ 'o' , // octal notation
199+ 'd' , // decimal notation (integer)
200+ 'x' , // lowercase hexadecimal notation
201+ 'X' , // uppercase hexadecimal notation
202+ 'c' , // character data
203+ 'n' , // like g, but with locale-specific thousand separator
204+ ] ) ;
205+
206+ // d3-format specifier: [[fill]align][sign][symbol][0][width][,][.precision][~][type]
207+ // Regex breakdown:
208+ // (?:(.)?([<>=^]))? - optional fill (any char) + align (<>=^)
209+ // ([+\-( ])? - optional sign (+, -, (, or space)
210+ // ([$#])? - optional symbol ($ or #)
211+ // (0)? - optional zero flag
212+ // (\d+)? - optional width (positive integer)
213+ // (,)? - optional comma flag (grouping)
214+ // (?:\.(\d+))? - optional precision (.N where N is non-negative integer)
215+ // (~)? - optional tilde (trim insignificant zeros)
216+ // ([a-zA-Z%])? - optional type character
217+ const NUMERIC_FORMAT_REGEX = / ^ (?: ( .) ? ( [ < > = ^ ] ) ) ? ( [ + \- ( ] ) ? ( [ $ # ] ) ? ( 0 ) ? ( \d + ) ? ( , ) ? (?: \. ( \d + ) ) ? ( ~ ) ? ( [ a - z A - Z % ] ) ? $ / ;
218+
219+ const customNumericFormatSchema = Joi . string ( ) . custom ( ( value , helper ) => {
220+ const match = value . match ( NUMERIC_FORMAT_REGEX ) ;
221+ if ( ! match ) {
222+ return helper . message ( {
223+ custom : `Invalid numeric format "${ value } ". Must be a valid d3-format specifier (e.g., ".2f", ",.0f", "$,.2f", ".0%", ".2s")`
224+ } ) ;
225+ }
226+
227+ const [ , fill , align , sign , symbol , zero , width , comma , precision , tilde , type ] = match ;
228+
229+ if ( fill && ! align ) {
230+ return helper . message ( {
231+ custom : `Invalid numeric format "${ value } ". Fill character requires alignment specifier (<, >, =, or ^)`
232+ } ) ;
233+ }
234+
235+ if ( type && ! NUMERIC_FORMAT_TYPES . has ( type . toLowerCase ( ) ) ) {
236+ return helper . message ( {
237+ custom : `Invalid numeric format "${ value } ". Unknown type character '${ type } '. Valid types: ${ [ ...NUMERIC_FORMAT_TYPES ] . join ( ', ' ) } `
238+ } ) ;
239+ }
240+
241+ // Validate that the format is not empty (must have at least something meaningful)
242+ if ( ! sign && ! symbol && ! zero && ! width && ! comma && precision === undefined && ! tilde && ! type ) {
243+ return helper . message ( {
244+ custom : `Invalid numeric format "${ value } ". Format must contain at least one specifier (e.g., type, precision, comma, sign, symbol)`
245+ } ) ;
246+ }
247+
248+ return value ;
249+ } ) ;
250+
251+ const measureFormatSchema = Joi . alternatives ( [
252+ Joi . string ( ) . valid ( 'percent' , 'currency' , 'number' ) ,
253+ customNumericFormatSchema
254+ ] ) ;
255+
256+ const dimensionNumericFormatSchema = Joi . alternatives ( [
257+ formatSchema ,
258+ customNumericFormatSchema
259+ ] ) ;
260+
185261const BaseDimensionWithoutSubQuery = {
186262 aliases : Joi . array ( ) . items ( Joi . string ( ) ) ,
187263 type : Joi . any ( ) . valid ( 'string' , 'number' , 'boolean' , 'time' , 'geo' ) . required ( ) ,
@@ -195,8 +271,10 @@ const BaseDimensionWithoutSubQuery = {
195271 suggestFilterValues : Joi . boolean ( ) . strict ( ) ,
196272 enableSuggestions : Joi . boolean ( ) . strict ( ) ,
197273 format : Joi . when ( 'type' , {
198- is : 'time' ,
199- then : timeFormatSchema ,
274+ switch : [
275+ { is : 'time' , then : timeFormatSchema } ,
276+ { is : 'number' , then : dimensionNumericFormatSchema } ,
277+ ] ,
200278 otherwise : formatSchema
201279 } ) ,
202280 meta : Joi . any ( ) ,
@@ -302,7 +380,7 @@ const ToDate = {
302380
303381const BaseMeasure = {
304382 aliases : Joi . array ( ) . items ( Joi . string ( ) ) ,
305- format : Joi . any ( ) . valid ( 'percent' , 'currency' , 'number' ) ,
383+ format : measureFormatSchema ,
306384 public : Joi . boolean ( ) . strict ( ) ,
307385 // TODO: Deprecate and remove, please use public
308386 visible : Joi . boolean ( ) . strict ( ) ,
0 commit comments