From 78d4c5ac3134f3170fe673d04450ef19522b9c18 Mon Sep 17 00:00:00 2001 From: zelixir <39115813+zelixir@users.noreply.github.com> Date: Thu, 14 Aug 2025 02:00:43 +0000 Subject: [PATCH] feat: support json_table in mysql/mariadb --- pegjs/mariadb.pegjs | 100 +++++++++++++++++++++++++++++++++++++ pegjs/mysql.pegjs | 100 +++++++++++++++++++++++++++++++++++++ src/tables.js | 84 ++++++++++++++++++++++++++++++- test/mysql-mariadb.spec.js | 37 +++++++++++++- types.d.ts | 23 ++++++++- 5 files changed, 341 insertions(+), 3 deletions(-) diff --git a/pegjs/mariadb.pegjs b/pegjs/mariadb.pegjs index 9e432815..a2adc0e8 100644 --- a/pegjs/mariadb.pegjs +++ b/pegjs/mariadb.pegjs @@ -2383,6 +2383,13 @@ table_base type: 'dual' }; } + / jt:json_table_expr __ alias:alias_clause? { + return { + expr: jt, + as: alias, + ...getLocationObject(), + }; + } / t:table_name __ alias:alias_clause? { if (t.type === 'var') { t.as = alias; @@ -2425,6 +2432,89 @@ table_base return result } +// JSON_TABLE expression +json_table_expr + = KW_JSON_TABLE __ LPAREN __ + expr:expr __ COMMA __ + path:literal_string __ + KW_COLUMNS __ LPAREN __ + columns:json_table_column_list __ + RPAREN __ RPAREN { + return { + type: 'json_table', + expr: expr, + path: path, + columns: columns, + ...getLocationObject(), + }; + } + +json_table_column_list + = head:json_table_column tail:(__ COMMA __ json_table_column)* { + return createList(head, tail); + } + +json_table_column + = name:ident_name __ KW_FOR __ KW_ORDINALITY { + return { + type: 'ordinality', + name: name, + ...getLocationObject(), + }; + } + / name:ident_name __ datatype:data_type __ KW_PATH __ path:literal_string __ + on_empty:json_table_on_empty? __ on_error:json_table_on_error? { + return { + type: 'column', + name: name, + datatype: datatype, + path: path, + on_empty: on_empty, + on_error: on_error, + ...getLocationObject(), + }; + } + / name:ident_name __ datatype:data_type __ KW_EXISTS __ KW_PATH __ path:literal_string { + return { + type: 'exists', + name: name, + datatype: datatype, + path: path, + ...getLocationObject(), + }; + } + / KW_NESTED __ KW_PATH? __ path:literal_string __ KW_COLUMNS __ LPAREN __ + columns:json_table_column_list __ RPAREN { + return { + type: 'nested', + path: path, + columns: columns, + ...getLocationObject(), + }; + } + +json_table_on_empty + = KW_NULL __ KW_ON __ KW_EMPTY { + return { type: 'null' }; + } + / KW_DEFAULT __ value:literal_string __ KW_ON __ KW_EMPTY { + return { type: 'default', value: value }; + } + / KW_ERROR __ KW_ON __ KW_EMPTY { + return { type: 'error' }; + } + +json_table_on_error + = KW_NULL __ KW_ON __ KW_ERROR { + return { type: 'null' }; + } + / KW_DEFAULT __ value:literal_string __ KW_ON __ KW_ERROR { + return { type: 'default', value: value }; + } + / KW_ERROR __ KW_ON __ KW_ERROR { + return { type: 'error' }; + } + join_op = KW_LEFT __ KW_OUTER? __ KW_JOIN { return 'LEFT JOIN'; } / KW_RIGHT __ KW_OUTER? __ KW_JOIN { return 'RIGHT JOIN'; } @@ -3986,6 +4076,16 @@ KW_CONSTRAINT = "CONSTRAINT"i !ident_start { return 'CONSTRAINT'; } KW_REFERENCES = "REFERENCES"i !ident_start { return 'REFERENCES'; } +// JSON_TABLE Keywords +KW_JSON_TABLE = "JSON_TABLE"i !ident_start { return 'JSON_TABLE'; } +KW_PATH = "PATH"i !ident_start { return 'PATH'; } +KW_COLUMNS = "COLUMNS"i !ident_start { return 'COLUMNS'; } +KW_NESTED = "NESTED"i !ident_start { return 'NESTED'; } +KW_ORDINALITY = "ORDINALITY"i !ident_start { return 'ORDINALITY'; } +KW_FOR = "FOR"i !ident_start { return 'FOR'; } +KW_EMPTY = "EMPTY"i !ident_start { return 'EMPTY'; } +KW_ERROR = "ERROR"i !ident_start { return 'ERROR'; } + // MySQL extensions to SQL OPT_SQL_CALC_FOUND_ROWS = "SQL_CALC_FOUND_ROWS"i diff --git a/pegjs/mysql.pegjs b/pegjs/mysql.pegjs index eb906910..8a4bbf16 100644 --- a/pegjs/mysql.pegjs +++ b/pegjs/mysql.pegjs @@ -2645,6 +2645,13 @@ table_base type: 'dual' }; } + / jt:json_table_expr __ alias:alias_clause? { + return { + expr: jt, + as: alias, + ...getLocationObject(), + }; + } / t:table_name __ alias:alias_clause? { if (t.type === 'var') { t.as = alias; @@ -2688,6 +2695,89 @@ table_base return result } +// JSON_TABLE expression +json_table_expr + = KW_JSON_TABLE __ LPAREN __ + expr:expr __ COMMA __ + path:literal_string __ + KW_COLUMNS __ LPAREN __ + columns:json_table_column_list __ + RPAREN __ RPAREN { + return { + type: 'json_table', + expr: expr, + path: path, + columns: columns, + ...getLocationObject(), + }; + } + +json_table_column_list + = head:json_table_column tail:(__ COMMA __ json_table_column)* { + return createList(head, tail); + } + +json_table_column + = name:ident_name __ KW_FOR __ KW_ORDINALITY { + return { + type: 'ordinality', + name: name, + ...getLocationObject(), + }; + } + / name:ident_name __ datatype:data_type __ KW_PATH __ path:literal_string __ + on_empty:json_table_on_empty? __ on_error:json_table_on_error? { + return { + type: 'column', + name: name, + datatype: datatype, + path: path, + on_empty: on_empty, + on_error: on_error, + ...getLocationObject(), + }; + } + / name:ident_name __ datatype:data_type __ KW_EXISTS __ KW_PATH __ path:literal_string { + return { + type: 'exists', + name: name, + datatype: datatype, + path: path, + ...getLocationObject(), + }; + } + / KW_NESTED __ KW_PATH? __ path:literal_string __ KW_COLUMNS __ LPAREN __ + columns:json_table_column_list __ RPAREN { + return { + type: 'nested', + path: path, + columns: columns, + ...getLocationObject(), + }; + } + +json_table_on_empty + = KW_NULL __ KW_ON __ KW_EMPTY { + return { type: 'null' }; + } + / KW_DEFAULT __ value:literal_string __ KW_ON __ KW_EMPTY { + return { type: 'default', value: value }; + } + / KW_ERROR __ KW_ON __ KW_EMPTY { + return { type: 'error' }; + } + +json_table_on_error + = KW_NULL __ KW_ON __ KW_ERROR { + return { type: 'null' }; + } + / KW_DEFAULT __ value:literal_string __ KW_ON __ KW_ERROR { + return { type: 'default', value: value }; + } + / KW_ERROR __ KW_ON __ KW_ERROR { + return { type: 'error' }; + } + join_op = KW_LEFT __ KW_OUTER? __ KW_JOIN { return 'LEFT JOIN'; } / KW_RIGHT __ KW_OUTER? __ KW_JOIN { return 'RIGHT JOIN'; } @@ -4284,6 +4374,16 @@ KW_COMMENT = "COMMENT"i !ident_start { return 'COMMENT'; } KW_CONSTRAINT = "CONSTRAINT"i !ident_start { return 'CONSTRAINT'; } KW_REFERENCES = "REFERENCES"i !ident_start { return 'REFERENCES'; } +// JSON_TABLE Keywords +KW_JSON_TABLE = "JSON_TABLE"i !ident_start { return 'JSON_TABLE'; } +KW_PATH = "PATH"i !ident_start { return 'PATH'; } +KW_COLUMNS = "COLUMNS"i !ident_start { return 'COLUMNS'; } +KW_NESTED = "NESTED"i !ident_start { return 'NESTED'; } +KW_ORDINALITY = "ORDINALITY"i !ident_start { return 'ORDINALITY'; } +KW_FOR = "FOR"i !ident_start { return 'FOR'; } +KW_EMPTY = "EMPTY"i !ident_start { return 'EMPTY'; } +KW_ERROR = "ERROR"i !ident_start { return 'ERROR'; } + // MySQL extensions to SQL OPT_SQL_CALC_FOUND_ROWS = "SQL_CALC_FOUND_ROWS"i OPT_SQL_CACHE = "SQL_CACHE"i diff --git a/src/tables.js b/src/tables.js index 55d1747b..b984a45f 100644 --- a/src/tables.js +++ b/src/tables.js @@ -3,7 +3,7 @@ import { columnRefToSQL } from './column' import { exprToSQL } from './expr' import { valuesToSQL } from './insert' import { intervalToSQL } from './interval' -import { commonOptionConnector, commonTypeValue, hasVal, identifierToSql, literalToSQL, toUpper } from './util' +import { commonOptionConnector, commonTypeValue, dataTypeToSQL, hasVal, identifierToSql, literalToSQL, toUpper } from './util' function unnestToSQL(unnestExpr) { const { type, as, expr, with_offset: withOffset } = unnestExpr @@ -112,6 +112,84 @@ function generateVirtualTable(stmt) { return `${toUpper(keyword)}(${toUpper(type)}(${generatorSQL}))` } +function jsonTableOnClauseToSQL(onClause, clauseType) { + const { type, value } = onClause + + switch (type) { + case 'null': + return `NULL ON ${clauseType}` + case 'default': + return `DEFAULT ${literalToSQL(value)} ON ${clauseType}` + case 'error': + return `ERROR ON ${clauseType}` + default: + return '' + } +} + +function jsonTableColumnToSQL(column) { + const { type, name, datatype, path, on_empty, on_error, columns } = column + + switch (type) { + case 'ordinality': + return `${identifierToSql(name)} FOR ORDINALITY` + + case 'column': + const result = [identifierToSql(name)] + if (datatype) { + result.push(dataTypeToSQL(datatype)) + } + result.push('PATH', literalToSQL(path)) + + if (on_empty) { + result.push(jsonTableOnClauseToSQL(on_empty, 'EMPTY')) + } + if (on_error) { + result.push(jsonTableOnClauseToSQL(on_error, 'ERROR')) + } + + return result.join(' ') + + case 'exists': + const existsResult = [identifierToSql(name)] + if (datatype) { + existsResult.push(dataTypeToSQL(datatype)) + } + existsResult.push('EXISTS PATH', literalToSQL(path)) + return existsResult.join(' ') + + case 'nested': + const nestedResult = ['NESTED PATH', literalToSQL(path), 'COLUMNS'] + if (columns && columns.length > 0) { + const columnsList = columns.map(jsonTableColumnToSQL).join(', ') + nestedResult.push(`(${columnsList})`) + } + return nestedResult.join(' ') + + default: + return '' + } +} + +function jsonTableToSQL(jsonTableExpr) { + const { expr, path, columns } = jsonTableExpr + + const result = ['JSON_TABLE('] + result.push(exprToSQL(expr)) + result.push(',') + result.push(literalToSQL(path)) + result.push('COLUMNS') + + if (columns && columns.length > 0) { + const columnsList = columns.map(jsonTableColumnToSQL).join(', ') + result.push(`(${columnsList})`) + } + + result.push(')') + + return result.join(' ') +} + function tableToSQL(tableInfo) { if (toUpper(tableInfo.type) === 'UNNEST') return unnestToSQL(tableInfo) const { table, db, as, expr, operator, prefix: prefixStr, schema, server, suffix, tablesample, temporal_table, table_hint, surround = {} } = tableInfo @@ -136,6 +214,9 @@ function tableToSQL(tableInfo) { case 'generator': tableName = generateVirtualTable(expr) break + case 'json_table': + tableName = jsonTableToSQL(expr) + break default: tableName = exprToSQL(expr) } @@ -223,4 +304,5 @@ export { tableOptionToSQL, tableToSQL, unnestToSQL, + jsonTableToSQL, } diff --git a/test/mysql-mariadb.spec.js b/test/mysql-mariadb.spec.js index 07efbafa..62eec394 100644 --- a/test/mysql-mariadb.spec.js +++ b/test/mysql-mariadb.spec.js @@ -1236,7 +1236,7 @@ describe('mysql', () => { ] }, { - titel: 'alter table truncate partiton', + title: 'alter table truncate partiton', sql: [ 'ALTER TABLE test_table TRUNCATE PARTITION p202503,p202504;', 'ALTER TABLE `test_table` TRUNCATE PARTITION `p202503`, `p202504`' @@ -1326,6 +1326,41 @@ describe('mysql', () => { "SELECT `crème` AS `brûlée` FROM `café` WHERE `âtre` = 'Molière'" ] }, + { + title: 'support json_table with basic columns', + sql: [ + `select * from json_table('{"name": "John", "age": 30}', '$' columns (name varchar(50) path '$.name', age int path '$.age')) as jt`, + `SELECT * FROM JSON_TABLE( '{"name": "John", "age": 30}' , '$' COLUMNS (\`name\` VARCHAR(50) PATH '$.name', \`age\` INT PATH '$.age') ) AS \`jt\`` + ] + }, + { + title: 'support json_table with ordinality', + sql: [ + `select * from json_table('[{"name": "John"}, {"name": "Jane"}]', '$[*]' columns (row_id for ordinality, name varchar(50) path '$.name')) as jt`, + `SELECT * FROM JSON_TABLE( '[{"name": "John"}, {"name": "Jane"}]' , '$[*]' COLUMNS (\`row_id\` FOR ORDINALITY, \`name\` VARCHAR(50) PATH '$.name') ) AS \`jt\`` + ] + }, + { + title: 'support json_table with exists', + sql: [ + `select * from json_table('{"users": [{"name": "John", "email": "john@example.com"}]}', '$.users[*]' columns (name varchar(50) path '$.name', has_email boolean exists path '$.email')) as jt`, + `SELECT * FROM JSON_TABLE( '{"users": [{"name": "John", "email": "john@example.com"}]}' , '$.users[*]' COLUMNS (\`name\` VARCHAR(50) PATH '$.name', \`has_email\` BOOLEAN EXISTS PATH '$.email') ) AS \`jt\`` + ] + }, + { + title: 'support json_table with nested columns', + sql: [ + `select * from json_table('{"users": [{"name": "John", "contacts": [{"type": "email", "value": "john@example.com"}]}]}', '$.users[*]' columns (name varchar(50) path '$.name', nested path '$.contacts[*]' columns (contact_type varchar(20) path '$.type', contact_value varchar(100) path '$.value'))) as jt`, + `SELECT * FROM JSON_TABLE( '{"users": [{"name": "John", "contacts": [{"type": "email", "value": "john@example.com"}]}]}' , '$.users[*]' COLUMNS (\`name\` VARCHAR(50) PATH '$.name', NESTED PATH '$.contacts[*]' COLUMNS (\`contact_type\` VARCHAR(20) PATH '$.type', \`contact_value\` VARCHAR(100) PATH '$.value')) ) AS \`jt\`` + ] + }, + { + title: 'support json_table with on empty and on error clauses', + sql: [ + `select * from json_table('{"data": [{"name": "John"}]}', '$.data[*]' columns (name varchar(50) path '$.name', age int path '$.age' default '0' on empty error on error)) as jt`, + `SELECT * FROM JSON_TABLE( '{"data": [{"name": "John"}]}' , '$.data[*]' COLUMNS (\`name\` VARCHAR(50) PATH '$.name', \`age\` INT PATH '$.age' DEFAULT '0' ON EMPTY ERROR ON ERROR) ) AS \`jt\`` + ] + } ] SQL_LIST.forEach(sqlInfo => { const { title, sql } = sqlInfo diff --git a/types.d.ts b/types.d.ts index ceb4e269..456e102a 100644 --- a/types.d.ts +++ b/types.d.ts @@ -57,7 +57,28 @@ export interface Dual { type: "dual"; loc?: LocationRange; } -export type From = BaseFrom | Join | TableExpr | Dual; + +export interface JsonTableColumn { + type: "ordinality" | "column" | "exists" | "nested"; + name?: string; + datatype?: DataType; + path?: ValueExpr; + on_empty?: { type: "null" | "default" | "error"; value?: ValueExpr }; + on_error?: { type: "null" | "default" | "error"; value?: ValueExpr }; + columns?: JsonTableColumn[]; + loc?: LocationRange; +} + +export interface JsonTable { + type: "json_table"; + expr: ExpressionValue; + path: ValueExpr; + columns: JsonTableColumn[]; + as?: string | null; + loc?: LocationRange; +} + +export type From = BaseFrom | Join | TableExpr | Dual | JsonTable; export interface LimitValue { type: string; value: number;