diff --git a/README.md b/README.md index e7a8db4521..d19272f2b5 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J | **MySQL table maintenance** | `ANALYZE` · `CHECK` · `CHECKSUM` · `OPTIMIZE` · `REPAIR TABLE` | | **PostgreSQL maintenance** | `VACUUM` · `ANALYZE` · `CLUSTER` · `CHECKPOINT` | | **MySQL locks** | `LOCK TABLES` · `UNLOCK TABLES` · `LOCK INSTANCE FOR BACKUP` · `UNLOCK INSTANCE` | +| **MySQL data loading** | `LOAD DATA` · `LOAD XML` · `SELECT … INTO OUTFILE` with the same `FIELDS` and `LINES` clauses | | **MySQL account management** | `CREATE`/`ALTER`/`RENAME USER` · `CREATE ROLE` · `SET ROLE` · `SET DEFAULT ROLE` · `SET PASSWORD` | | **Tablespaces** | MySQL and PostgreSQL `CREATE TABLESPACE` · `ALTER TABLESPACE` | | **PostgreSQL RLS** | `CREATE POLICY` · `ALTER TABLE … ENABLE`/`DISABLE`/`FORCE`/`NO FORCE ROW LEVEL SECURITY` | diff --git a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java index 447245f4aa..1e7ca0b975 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -1153,10 +1153,12 @@ public enum Feature { export, /** - * PostgreSQL and DuckDB "COPY" and BigQuery "LOAD DATA" statements are allowed + * PostgreSQL and DuckDB "COPY", BigQuery and MySQL "LOAD DATA" and MySQL "LOAD XML" statements + * are allowed * * @see net.sf.jsqlparser.statement.CopyStatement * @see net.sf.jsqlparser.statement.load.LoadDataStatement + * @see net.sf.jsqlparser.statement.load.MySqlLoadDataStatement */ copy, loadData, diff --git a/src/main/java/net/sf/jsqlparser/statement/MySqlFileFormat.java b/src/main/java/net/sf/jsqlparser/statement/MySqlFileFormat.java new file mode 100644 index 0000000000..1460604ec0 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/MySqlFileFormat.java @@ -0,0 +1,196 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement; + +import java.io.Serializable; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** + * The {@code {FIELDS | COLUMNS}} and {@code LINES} clauses that describe the text file of a MySQL + * {@code LOAD DATA} or {@code SELECT ... INTO OUTFILE}: + * + *
+ * [{FIELDS | COLUMNS} [TERMINATED BY 'string'] [[OPTIONALLY] ENCLOSED BY 'char'] [ESCAPED BY 'char']]
+ * [LINES [STARTING BY 'string'] [TERMINATED BY 'string']]
+ * 
+ * + * Each clause has at least one subclause. MySQL accepts the subclauses of a clause in any order and + * more than once; like the server, the model keeps the last value of each and prints them in the + * order above. {@code OPTIONALLY} stays set once a subclause has written it. + * + * @see LOAD DATA + */ +public class MySqlFileFormat extends ASTNodeAccessImpl implements Serializable { + + /** The spelling of the fields clause. */ + public enum FieldsKeyword { + FIELDS, COLUMNS + } + + private FieldsKeyword fieldsKeyword; + private StringValue fieldsTerminatedBy; + private boolean fieldsOptionallyEnclosed; + private StringValue fieldsEnclosedBy; + private StringValue fieldsEscapedBy; + private StringValue linesStartingBy; + private StringValue linesTerminatedBy; + + /** The spelling of the fields clause; null prints FIELDS when a subclause is set. */ + public FieldsKeyword getFieldsKeyword() { + return fieldsKeyword; + } + + public void setFieldsKeyword(FieldsKeyword fieldsKeyword) { + this.fieldsKeyword = fieldsKeyword; + } + + public MySqlFileFormat withFieldsKeyword(FieldsKeyword fieldsKeyword) { + setFieldsKeyword(fieldsKeyword); + return this; + } + + public StringValue getFieldsTerminatedBy() { + return fieldsTerminatedBy; + } + + public void setFieldsTerminatedBy(StringValue fieldsTerminatedBy) { + this.fieldsTerminatedBy = fieldsTerminatedBy; + } + + public MySqlFileFormat withFieldsTerminatedBy(StringValue fieldsTerminatedBy) { + setFieldsTerminatedBy(fieldsTerminatedBy); + return this; + } + + public boolean isFieldsOptionallyEnclosed() { + return fieldsOptionallyEnclosed; + } + + public void setFieldsOptionallyEnclosed(boolean fieldsOptionallyEnclosed) { + this.fieldsOptionallyEnclosed = fieldsOptionallyEnclosed; + } + + public MySqlFileFormat withFieldsOptionallyEnclosed(boolean fieldsOptionallyEnclosed) { + setFieldsOptionallyEnclosed(fieldsOptionallyEnclosed); + return this; + } + + public StringValue getFieldsEnclosedBy() { + return fieldsEnclosedBy; + } + + public void setFieldsEnclosedBy(StringValue fieldsEnclosedBy) { + this.fieldsEnclosedBy = fieldsEnclosedBy; + } + + public MySqlFileFormat withFieldsEnclosedBy(StringValue fieldsEnclosedBy) { + setFieldsEnclosedBy(fieldsEnclosedBy); + return this; + } + + public StringValue getFieldsEscapedBy() { + return fieldsEscapedBy; + } + + public void setFieldsEscapedBy(StringValue fieldsEscapedBy) { + this.fieldsEscapedBy = fieldsEscapedBy; + } + + public MySqlFileFormat withFieldsEscapedBy(StringValue fieldsEscapedBy) { + setFieldsEscapedBy(fieldsEscapedBy); + return this; + } + + public StringValue getLinesStartingBy() { + return linesStartingBy; + } + + public void setLinesStartingBy(StringValue linesStartingBy) { + this.linesStartingBy = linesStartingBy; + } + + public MySqlFileFormat withLinesStartingBy(StringValue linesStartingBy) { + setLinesStartingBy(linesStartingBy); + return this; + } + + public StringValue getLinesTerminatedBy() { + return linesTerminatedBy; + } + + public void setLinesTerminatedBy(StringValue linesTerminatedBy) { + this.linesTerminatedBy = linesTerminatedBy; + } + + public MySqlFileFormat withLinesTerminatedBy(StringValue linesTerminatedBy) { + setLinesTerminatedBy(linesTerminatedBy); + return this; + } + + /** Whether the format prints a fields clause: a spelling or a subclause is set. */ + public boolean hasFieldsClause() { + return fieldsKeyword != null || fieldsTerminatedBy != null || fieldsEnclosedBy != null + || fieldsEscapedBy != null; + } + + /** Whether the format prints a lines clause: a subclause is set. */ + public boolean hasLinesClause() { + return linesStartingBy != null || linesTerminatedBy != null; + } + + /** Whether the format prints nothing. */ + public boolean isEmpty() { + return !hasFieldsClause() && !hasLinesClause(); + } + + /** Appends the fields clause and then the lines clause, separated by a space. */ + public StringBuilder appendTo(StringBuilder builder) { + if (hasFieldsClause()) { + appendFieldsClause(builder); + if (hasLinesClause()) { + builder.append(' '); + } + } + if (hasLinesClause()) { + appendLinesClause(builder); + } + return builder; + } + + private void appendFieldsClause(StringBuilder builder) { + builder.append(fieldsKeyword != null ? fieldsKeyword : FieldsKeyword.FIELDS); + if (fieldsTerminatedBy != null) { + builder.append(" TERMINATED BY ").append(fieldsTerminatedBy); + } + if (fieldsEnclosedBy != null) { + builder.append(fieldsOptionallyEnclosed ? " OPTIONALLY ENCLOSED BY " : " ENCLOSED BY ") + .append(fieldsEnclosedBy); + } + if (fieldsEscapedBy != null) { + builder.append(" ESCAPED BY ").append(fieldsEscapedBy); + } + } + + private void appendLinesClause(StringBuilder builder) { + builder.append("LINES"); + if (linesStartingBy != null) { + builder.append(" STARTING BY ").append(linesStartingBy); + } + if (linesTerminatedBy != null) { + builder.append(" TERMINATED BY ").append(linesTerminatedBy); + } + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index f91ea876a6..e6f26f11a9 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -98,6 +98,7 @@ import net.sf.jsqlparser.statement.insert.Insert; import net.sf.jsqlparser.statement.insert.InsertBulk; import net.sf.jsqlparser.statement.load.LoadDataStatement; +import net.sf.jsqlparser.statement.load.MySqlLoadDataStatement; import net.sf.jsqlparser.statement.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.LockStatement; import net.sf.jsqlparser.statement.lock.UnlockStatement; @@ -680,6 +681,13 @@ public Void visit(LoadDataStatement loadDataStatement, S context) { return null; } + @Override + public Void visit(MySqlLoadDataStatement loadData, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_DATA); + return super.visit(loadData, context); + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { analysis.claimTopLevel(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index bd9119530e..18a534accb 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -113,6 +113,7 @@ import net.sf.jsqlparser.statement.create.extension.CreateExtensionRepository; import net.sf.jsqlparser.statement.export.ExportDataStatement; import net.sf.jsqlparser.statement.load.LoadDataStatement; +import net.sf.jsqlparser.statement.load.MySqlLoadDataStatement; public interface StatementVisitor { @@ -660,6 +661,14 @@ default void visit(LoadDataStatement loadDataStatement) { this.visit(loadDataStatement, null); } + default T visit(MySqlLoadDataStatement loadData, S context) { + return null; + } + + default void visit(MySqlLoadDataStatement loadData) { + this.visit(loadData, null); + } + T visit(PurgeStatement purgeStatement, S context); default void visit(PurgeStatement purgeStatement) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 32a947382e..1cb164a7c0 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -130,6 +130,7 @@ import net.sf.jsqlparser.statement.create.extension.CreateExtensionRepository; import net.sf.jsqlparser.statement.export.ExportDataStatement; import net.sf.jsqlparser.statement.load.LoadDataStatement; +import net.sf.jsqlparser.statement.load.MySqlLoadDataStatement; @SuppressWarnings({"PMD.UncommentedEmptyMethodBody"}) public class StatementVisitorAdapter implements StatementVisitor { @@ -850,6 +851,14 @@ public T visit(LoadDataStatement loadDataStatement, S context) { return null; } + @Override + public T visit(MySqlLoadDataStatement loadData, S context) { + fromItemVisitor.visitFromItem(loadData.getTable(), context); + expressionVisitor.visitExpressions(loadData.getColumns(), context); + expressionVisitor.visitUpdateSets(loadData.getUpdateSets(), context); + return null; + } + @Override public T visit(PurgeStatement purgeStatement, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/statement/load/LoadDataStatement.java b/src/main/java/net/sf/jsqlparser/statement/load/LoadDataStatement.java index 16c8a42b0f..545e6f50c8 100644 --- a/src/main/java/net/sf/jsqlparser/statement/load/LoadDataStatement.java +++ b/src/main/java/net/sf/jsqlparser/statement/load/LoadDataStatement.java @@ -9,6 +9,7 @@ */ package net.sf.jsqlparser.statement.load; +import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.operators.relational.ExpressionList; import net.sf.jsqlparser.parser.ASTNodeAccessImpl; import net.sf.jsqlparser.schema.Table; @@ -18,6 +19,7 @@ import net.sf.jsqlparser.statement.select.PlainSelect; import java.util.List; +import java.util.function.Consumer; /** * BigQuery's {@code LOAD DATA {INTO | OVERWRITE} table [(column_definitions)] [OPTIONS @@ -128,14 +130,19 @@ public LoadDataStatement withConnectionName(String connectionName) { } public StringBuilder appendTo(StringBuilder builder) { + return appendTo(builder, builder::append); + } + + /** Renders the options and file options through the caller's expression visitor. */ + public StringBuilder appendTo(StringBuilder builder, Consumer expressionRenderer) { builder.append("LOAD DATA ").append(overwrite ? "OVERWRITE " : "INTO ").append(table); if (columnDefinitions != null && !columnDefinitions.isEmpty()) { builder.append(" ").append(PlainSelect.getStringList(columnDefinitions, true, true)); } if (options != null) { - builder.append(" OPTIONS (").append(options).append(")"); + appendOptions(builder.append(" OPTIONS ("), options, expressionRenderer).append(")"); } - builder.append(" FROM FILES (").append(fileOptions).append(")"); + appendOptions(builder.append(" FROM FILES ("), fileOptions, expressionRenderer).append(")"); if (withPartitionColumns) { builder.append(" WITH PARTITION COLUMNS"); } @@ -145,6 +152,19 @@ public StringBuilder appendTo(StringBuilder builder) { return builder; } + private static StringBuilder appendOptions(StringBuilder builder, ExpressionList options, + Consumer expressionRenderer) { + if (options != null) { + for (int i = 0; i < options.size(); i++) { + if (i > 0) { + builder.append(", "); + } + expressionRenderer.accept(options.get(i)); + } + } + return builder; + } + @Override public String toString() { return appendTo(new StringBuilder()).toString(); diff --git a/src/main/java/net/sf/jsqlparser/statement/load/MySqlLoadDataStatement.java b/src/main/java/net/sf/jsqlparser/statement/load/MySqlLoadDataStatement.java new file mode 100644 index 0000000000..e6c632a6b4 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/load/MySqlLoadDataStatement.java @@ -0,0 +1,370 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.load; + +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.MySqlFileFormat; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.update.UpdateSet; + +/** + * MySQL's {@code LOAD DATA} and {@code LOAD XML}: + * + *
+ * LOAD {DATA | XML} [LOW_PRIORITY | CONCURRENT] [LOCAL] INFILE 'file_name' [REPLACE | IGNORE]
+ *     INTO TABLE tbl_name [PARTITION (partition_name [, partition_name] ...)]
+ *     [CHARACTER SET charset_name] [ROWS IDENTIFIED BY '<tagname>']
+ *     [{FIELDS | COLUMNS} ...] [LINES ...] [IGNORE number {LINES | ROWS}]
+ *     [(col_name_or_user_var [, col_name_or_user_var] ...)]
+ *     [SET col_name = {expr | DEFAULT} [, col_name = {expr | DEFAULT}] ...]
+ * 
+ * + * Both forms share one grammar in MySQL, so every clause is available to both. The FIELDS and LINES + * clauses are the {@link MySqlFileFormat} of {@code SELECT ... INTO OUTFILE}. BigQuery's + * {@code LOAD DATA INTO} is {@link LoadDataStatement}. + * + * @see LOAD DATA + * @see LOAD XML + */ +public class MySqlLoadDataStatement extends ASTNodeAccessImpl implements Statement { + + /** The word after LOAD: a delimited text file or an XML file. */ + public enum Format { + DATA, XML + } + + /** When the rows are written while other sessions use the table. */ + public enum LockOption { + LOW_PRIORITY, CONCURRENT + } + + /** The word of {@code IGNORE number {LINES | ROWS}}. */ + public enum IgnoreUnit { + LINES, ROWS + } + + private Format format = Format.DATA; + private LockOption lockOption; + private boolean local; + private StringValue fileName; + private CreateTable.DuplicateHandling duplicateHandling; + private Table table; + private List partitions; + private String characterSet; + private boolean useCharsetKeyword; + private StringValue rowsIdentifiedBy; + private MySqlFileFormat fileFormat; + private Long ignoreCount; + private IgnoreUnit ignoreUnit = IgnoreUnit.LINES; + private ExpressionList columns; + private List updateSets; + + public Format getFormat() { + return format; + } + + public void setFormat(Format format) { + this.format = format; + } + + public MySqlLoadDataStatement withFormat(Format format) { + setFormat(format); + return this; + } + + public LockOption getLockOption() { + return lockOption; + } + + public void setLockOption(LockOption lockOption) { + this.lockOption = lockOption; + } + + public MySqlLoadDataStatement withLockOption(LockOption lockOption) { + setLockOption(lockOption); + return this; + } + + /** Whether the client reads the file ({@code LOCAL}) rather than the server. */ + public boolean isLocal() { + return local; + } + + public void setLocal(boolean local) { + this.local = local; + } + + public MySqlLoadDataStatement withLocal(boolean local) { + setLocal(local); + return this; + } + + public StringValue getFileName() { + return fileName; + } + + public void setFileName(StringValue fileName) { + this.fileName = fileName; + } + + public MySqlLoadDataStatement withFileName(StringValue fileName) { + setFileName(fileName); + return this; + } + + /** {@code REPLACE} or {@code IGNORE} for rows that duplicate a unique key, or null. */ + public CreateTable.DuplicateHandling getDuplicateHandling() { + return duplicateHandling; + } + + public void setDuplicateHandling(CreateTable.DuplicateHandling duplicateHandling) { + this.duplicateHandling = duplicateHandling; + } + + public MySqlLoadDataStatement withDuplicateHandling( + CreateTable.DuplicateHandling duplicateHandling) { + setDuplicateHandling(duplicateHandling); + return this; + } + + public Table getTable() { + return table; + } + + public void setTable(Table table) { + this.table = table; + } + + public MySqlLoadDataStatement withTable(Table table) { + setTable(table); + return this; + } + + /** The names of {@code PARTITION (p0, p1)}, or null without the clause. */ + public List getPartitions() { + return partitions; + } + + public void setPartitions(List partitions) { + this.partitions = partitions; + } + + public MySqlLoadDataStatement withPartitions(List partitions) { + setPartitions(partitions); + return this; + } + + /** The character set name as written, a quoted name keeping its quotes. */ + public String getCharacterSet() { + return characterSet; + } + + public void setCharacterSet(String characterSet) { + this.characterSet = characterSet; + } + + public MySqlLoadDataStatement withCharacterSet(String characterSet) { + setCharacterSet(characterSet); + return this; + } + + /** Whether the character set clause is spelled CHARSET rather than CHARACTER SET. */ + public boolean isUseCharsetKeyword() { + return useCharsetKeyword; + } + + public void setUseCharsetKeyword(boolean useCharsetKeyword) { + this.useCharsetKeyword = useCharsetKeyword; + } + + public MySqlLoadDataStatement withUseCharsetKeyword(boolean useCharsetKeyword) { + setUseCharsetKeyword(useCharsetKeyword); + return this; + } + + /** The XML tag of {@code ROWS IDENTIFIED BY ''}, or null. */ + public StringValue getRowsIdentifiedBy() { + return rowsIdentifiedBy; + } + + public void setRowsIdentifiedBy(StringValue rowsIdentifiedBy) { + this.rowsIdentifiedBy = rowsIdentifiedBy; + } + + public MySqlLoadDataStatement withRowsIdentifiedBy(StringValue rowsIdentifiedBy) { + setRowsIdentifiedBy(rowsIdentifiedBy); + return this; + } + + /** The FIELDS and LINES clauses, or null without either. */ + public MySqlFileFormat getFileFormat() { + return fileFormat; + } + + public void setFileFormat(MySqlFileFormat fileFormat) { + this.fileFormat = fileFormat; + } + + public MySqlLoadDataStatement withFileFormat(MySqlFileFormat fileFormat) { + setFileFormat(fileFormat); + return this; + } + + /** The number of {@code IGNORE number {LINES | ROWS}}, or null without the clause. */ + public Long getIgnoreCount() { + return ignoreCount; + } + + public void setIgnoreCount(Long ignoreCount) { + this.ignoreCount = ignoreCount; + } + + public MySqlLoadDataStatement withIgnoreCount(Long ignoreCount) { + setIgnoreCount(ignoreCount); + return this; + } + + public IgnoreUnit getIgnoreUnit() { + return ignoreUnit; + } + + public void setIgnoreUnit(IgnoreUnit ignoreUnit) { + this.ignoreUnit = ignoreUnit; + } + + public MySqlLoadDataStatement withIgnoreUnit(IgnoreUnit ignoreUnit) { + setIgnoreUnit(ignoreUnit); + return this; + } + + /** + * The columns and user variables the input fields go to: {@code Column} and + * {@code UserVariable} elements. Null without a list, empty for {@code ()}. + */ + public ExpressionList getColumns() { + return columns; + } + + public void setColumns(ExpressionList columns) { + this.columns = columns; + } + + public MySqlLoadDataStatement withColumns(ExpressionList columns) { + setColumns(columns); + return this; + } + + /** The assignments of the SET clause, each one column and one value, or null. */ + public List getUpdateSets() { + return updateSets; + } + + public void setUpdateSets(List updateSets) { + this.updateSets = updateSets; + } + + public MySqlLoadDataStatement withUpdateSets(List updateSets) { + setUpdateSets(updateSets); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + return appendTo(builder, builder::append); + } + + /** Renders the column list and the SET clause through the caller's expression visitor. */ + public StringBuilder appendTo(StringBuilder builder, Consumer expressionRenderer) { + appendSource(builder); + appendTarget(builder); + appendFileOptions(builder); + if (columns != null) { + builder.append(" ("); + appendList(builder, columns, expressionRenderer); + builder.append(")"); + } + if (updateSets != null && !updateSets.isEmpty()) { + builder.append(" SET "); + for (int i = 0; i < updateSets.size(); i++) { + if (i > 0) { + builder.append(", "); + } + appendList(builder, updateSets.get(i).getColumns(), expressionRenderer); + builder.append(" = "); + appendList(builder, updateSets.get(i).getValues(), expressionRenderer); + } + } + return builder; + } + + private void appendSource(StringBuilder builder) { + builder.append("LOAD ").append(format); + if (lockOption != null) { + builder.append(' ').append(lockOption); + } + if (local) { + builder.append(" LOCAL"); + } + builder.append(" INFILE ").append(fileName); + if (duplicateHandling != null) { + builder.append(' ').append(duplicateHandling); + } + } + + private void appendTarget(StringBuilder builder) { + builder.append(" INTO TABLE ").append(table); + if (partitions != null) { + builder.append(" PARTITION (").append(String.join(", ", partitions)).append(")"); + } + if (characterSet != null) { + builder.append(useCharsetKeyword ? " CHARSET " : " CHARACTER SET ") + .append(characterSet); + } + } + + private void appendFileOptions(StringBuilder builder) { + if (rowsIdentifiedBy != null) { + builder.append(" ROWS IDENTIFIED BY ").append(rowsIdentifiedBy); + } + if (fileFormat != null && !fileFormat.isEmpty()) { + fileFormat.appendTo(builder.append(' ')); + } + if (ignoreCount != null) { + builder.append(" IGNORE ").append(ignoreCount).append(' ').append(ignoreUnit); + } + } + + private static void appendList(StringBuilder builder, List expressions, + Consumer expressionRenderer) { + for (int i = 0; i < expressions.size(); i++) { + if (i > 0) { + builder.append(", "); + } + expressionRenderer.accept(expressions.get(i)); + } + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/select/MySqlSelectIntoClause.java b/src/main/java/net/sf/jsqlparser/statement/select/MySqlSelectIntoClause.java index 16a0755fc0..dbb0c2dea5 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/MySqlSelectIntoClause.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/MySqlSelectIntoClause.java @@ -16,6 +16,7 @@ import net.sf.jsqlparser.expression.UserVariable; import net.sf.jsqlparser.expression.operators.relational.ExpressionList; import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.statement.MySqlFileFormat; public class MySqlSelectIntoClause extends ASTNodeAccessImpl implements Serializable { @@ -27,6 +28,10 @@ public enum Type { OUTFILE, DUMPFILE, VARIABLES } + /** + * The spelling of the fields clause, as {@link #getFieldsKeyword()} reports it from + * {@link MySqlFileFormat.FieldsKeyword}. + */ public enum FieldsKeyword { FIELDS, COLUMNS } @@ -36,13 +41,8 @@ public enum FieldsKeyword { private StringValue fileName; private ExpressionList variables; private String characterSet; - private FieldsKeyword fieldsKeyword; - private StringValue fieldsTerminatedBy; - private boolean fieldsOptionallyEnclosed; - private StringValue fieldsEnclosedBy; - private StringValue fieldsEscapedBy; - private StringValue linesStartingBy; - private StringValue linesTerminatedBy; + private boolean useCharsetKeyword; + private MySqlFileFormat fileFormat; public Position getPosition() { return position; @@ -89,69 +89,105 @@ public void setCharacterSet(String characterSet) { this.characterSet = characterSet; } + /** Whether the character set clause is spelled CHARSET rather than CHARACTER SET. */ + public boolean isUseCharsetKeyword() { + return useCharsetKeyword; + } + + public void setUseCharsetKeyword(boolean useCharsetKeyword) { + this.useCharsetKeyword = useCharsetKeyword; + } + + /** + * The FIELDS and LINES clauses of INTO OUTFILE, shared with LOAD DATA. The getters and setters + * of the separate subclauses below read and write this format. + */ + public MySqlFileFormat getFileFormat() { + return fileFormat; + } + + public void setFileFormat(MySqlFileFormat fileFormat) { + this.fileFormat = fileFormat; + } + + public MySqlSelectIntoClause withFileFormat(MySqlFileFormat fileFormat) { + setFileFormat(fileFormat); + return this; + } + + private MySqlFileFormat fileFormat() { + if (fileFormat == null) { + fileFormat = new MySqlFileFormat(); + } + return fileFormat; + } + public FieldsKeyword getFieldsKeyword() { - return fieldsKeyword; + MySqlFileFormat.FieldsKeyword keyword = + fileFormat != null ? fileFormat.getFieldsKeyword() : null; + return keyword != null ? FieldsKeyword.valueOf(keyword.name()) : null; } public void setFieldsKeyword(FieldsKeyword fieldsKeyword) { - this.fieldsKeyword = fieldsKeyword; + fileFormat().setFieldsKeyword(fieldsKeyword != null + ? MySqlFileFormat.FieldsKeyword.valueOf(fieldsKeyword.name()) + : null); } public StringValue getFieldsTerminatedBy() { - return fieldsTerminatedBy; + return fileFormat != null ? fileFormat.getFieldsTerminatedBy() : null; } public void setFieldsTerminatedBy(StringValue fieldsTerminatedBy) { - this.fieldsTerminatedBy = fieldsTerminatedBy; + fileFormat().setFieldsTerminatedBy(fieldsTerminatedBy); } public boolean isFieldsOptionallyEnclosed() { - return fieldsOptionallyEnclosed; + return fileFormat != null && fileFormat.isFieldsOptionallyEnclosed(); } public void setFieldsOptionallyEnclosed(boolean fieldsOptionallyEnclosed) { - this.fieldsOptionallyEnclosed = fieldsOptionallyEnclosed; + fileFormat().setFieldsOptionallyEnclosed(fieldsOptionallyEnclosed); } public StringValue getFieldsEnclosedBy() { - return fieldsEnclosedBy; + return fileFormat != null ? fileFormat.getFieldsEnclosedBy() : null; } public void setFieldsEnclosedBy(StringValue fieldsEnclosedBy) { - this.fieldsEnclosedBy = fieldsEnclosedBy; + fileFormat().setFieldsEnclosedBy(fieldsEnclosedBy); } public StringValue getFieldsEscapedBy() { - return fieldsEscapedBy; + return fileFormat != null ? fileFormat.getFieldsEscapedBy() : null; } public void setFieldsEscapedBy(StringValue fieldsEscapedBy) { - this.fieldsEscapedBy = fieldsEscapedBy; + fileFormat().setFieldsEscapedBy(fieldsEscapedBy); } public StringValue getLinesStartingBy() { - return linesStartingBy; + return fileFormat != null ? fileFormat.getLinesStartingBy() : null; } public void setLinesStartingBy(StringValue linesStartingBy) { - this.linesStartingBy = linesStartingBy; + fileFormat().setLinesStartingBy(linesStartingBy); } public StringValue getLinesTerminatedBy() { - return linesTerminatedBy; + return fileFormat != null ? fileFormat.getLinesTerminatedBy() : null; } public void setLinesTerminatedBy(StringValue linesTerminatedBy) { - this.linesTerminatedBy = linesTerminatedBy; + fileFormat().setLinesTerminatedBy(linesTerminatedBy); } public boolean hasFieldsClause() { - return fieldsKeyword != null || fieldsTerminatedBy != null || fieldsEnclosedBy != null - || fieldsEscapedBy != null; + return fileFormat != null && fileFormat.hasFieldsClause(); } public boolean hasLinesClause() { - return linesStartingBy != null || linesTerminatedBy != null; + return fileFormat != null && fileFormat.hasLinesClause(); } public StringBuilder appendTo(StringBuilder builder) { @@ -168,59 +204,17 @@ public StringBuilder appendTo(StringBuilder builder, Consumer expres return builder; } builder.append(type); - appendFileName(builder); - appendCharacterSet(builder); - appendFieldsClause(builder); - appendLinesClause(builder); - return builder; - } - - private void appendFileName(StringBuilder builder) { if (fileName != null) { builder.append(" ").append(fileName); } - } - - private void appendCharacterSet(StringBuilder builder) { if (characterSet != null) { - builder.append(" CHARACTER SET ").append(characterSet); - } - } - - private void appendFieldsClause(StringBuilder builder) { - if (!hasFieldsClause()) { - return; + builder.append(useCharsetKeyword ? " CHARSET " : " CHARACTER SET ") + .append(characterSet); } - - builder.append(" ").append(fieldsKeyword != null ? fieldsKeyword : FieldsKeyword.FIELDS); - - if (fieldsTerminatedBy != null) { - builder.append(" TERMINATED BY ").append(fieldsTerminatedBy); - } - if (fieldsEnclosedBy != null) { - builder.append(" "); - if (fieldsOptionallyEnclosed) { - builder.append("OPTIONALLY "); - } - builder.append("ENCLOSED BY ").append(fieldsEnclosedBy); - } - if (fieldsEscapedBy != null) { - builder.append(" ESCAPED BY ").append(fieldsEscapedBy); - } - } - - private void appendLinesClause(StringBuilder builder) { - if (!hasLinesClause()) { - return; - } - - builder.append(" LINES"); - if (linesStartingBy != null) { - builder.append(" STARTING BY ").append(linesStartingBy); - } - if (linesTerminatedBy != null) { - builder.append(" TERMINATED BY ").append(linesTerminatedBy); + if (fileFormat != null && !fileFormat.isEmpty()) { + fileFormat.appendTo(builder.append(" ")); } + return builder; } @Override diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 87444c8e5f..a5c0945a9d 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -196,6 +196,7 @@ import net.sf.jsqlparser.statement.insert.OracleMultiInsertClause; import net.sf.jsqlparser.statement.insert.ParenthesedInsert; import net.sf.jsqlparser.statement.load.LoadDataStatement; +import net.sf.jsqlparser.statement.load.MySqlLoadDataStatement; import net.sf.jsqlparser.statement.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.LockStatement; import net.sf.jsqlparser.statement.lock.UnlockStatement; @@ -2912,6 +2913,17 @@ public Void visit(LoadDataStatement loadDataStatement, S context) { return null; } + @Override + public Void visit(MySqlLoadDataStatement loadData, S context) { + visitTargetTable(loadData.getTable(), context); + if (loadData.getUpdateSets() != null) { + for (UpdateSet updateSet : loadData.getUpdateSets()) { + updateSet.getValues().accept(this, context); + } + } + return null; + } + @Override public void visit(AttachStatement attachStatement) { StatementVisitor.super.visit(attachStatement); @@ -3018,6 +3030,11 @@ public void visit(LoadDataStatement loadDataStatement) { StatementVisitor.super.visit(loadDataStatement); } + @Override + public void visit(MySqlLoadDataStatement loadData) { + StatementVisitor.super.visit(loadData); + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { if (purgeStatement.getPurgeObjectType() == PurgeObjectType.TABLE) { diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index c7c959128b..4e9bed5719 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -158,6 +158,7 @@ import net.sf.jsqlparser.statement.AssertStatement; import net.sf.jsqlparser.statement.export.ExportDataStatement; import net.sf.jsqlparser.statement.load.LoadDataStatement; +import net.sf.jsqlparser.statement.load.MySqlLoadDataStatement; public class StatementDeParser extends AbstractDeParser implements StatementVisitor { @@ -787,10 +788,17 @@ public StringBuilder visit(CreateExtensionRepository createExtensionReposito @Override public StringBuilder visit(LoadDataStatement loadDataStatement, S context) { - loadDataStatement.appendTo(builder); + loadDataStatement.appendTo(builder, + expression -> expression.accept(expressionDeParser, context)); return builder; } + @Override + public StringBuilder visit(MySqlLoadDataStatement loadData, S context) { + return loadData.appendTo(builder, + expression -> expression.accept(expressionDeParser, context)); + } + @Override public StringBuilder visit(PurgeStatement purgeStatement, S context) { purgeStatement.appendTo(builder); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java index f4ff88e851..3f89a41dc9 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java @@ -86,6 +86,10 @@ public enum MariaDbVersion implements Version { // https://mariadb.com/kb/en/lock-tables/ Feature.lock, Feature.unlockTables, + // https://mariadb.com/kb/en/load-data-infile/ + // https://mariadb.com/kb/en/load-xml/ + Feature.loadData, + // https://mariadb.com/kb/en/account-management-sql-statements/ Feature.createRole, Feature.createUser, Feature.alterUser, Feature.renameUser, Feature.setRole, diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java index 26c630ea34..d75f1b1de2 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java @@ -85,6 +85,10 @@ public enum MySqlVersion implements Version { // https://dev.mysql.com/doc/refman/8.0/en/lock-instance-for-backup.html Feature.backupLock, + // https://dev.mysql.com/doc/refman/8.0/en/load-data.html + // https://dev.mysql.com/doc/refman/8.0/en/load-xml.html + Feature.loadData, + // https://dev.mysql.com/doc/refman/8.0/en/account-management-statements.html Feature.createRole, Feature.createUser, Feature.alterUser, Feature.renameUser, Feature.setRole, diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index ee7cfd848f..e4e3cf5377 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -166,6 +166,7 @@ import net.sf.jsqlparser.statement.AssertStatement; import net.sf.jsqlparser.statement.export.ExportDataStatement; import net.sf.jsqlparser.statement.load.LoadDataStatement; +import net.sf.jsqlparser.statement.load.MySqlLoadDataStatement; import java.util.List; @@ -834,6 +835,15 @@ public Void visit(LoadDataStatement loadDataStatement, S context) { return null; } + @Override + public Void visit(MySqlLoadDataStatement loadData, S context) { + validateFeature(Feature.loadData); + validateOptionalFromItem(loadData.getTable()); + validateOptionalExpressions(loadData.getColumns()); + validateOptionalUpdateSets(loadData.getUpdateSets()); + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { validateFeature(Feature.purge); @@ -1178,6 +1188,10 @@ public void visit(LoadDataStatement loadDataStatement) { visit(loadDataStatement, null); } + public void visit(MySqlLoadDataStatement loadData) { + visit(loadData, null); + } + public void visit(PurgeStatement purgeStatement) { visit(purgeStatement, null); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 13f4f1b62f..55c367f913 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2573,6 +2573,50 @@ public class CCJSqlParser extends AbstractJSqlParser { && isMySqlLockDialect(false); } + /** + * Whether MySQL's LOAD {DATA | XML} [LOW_PRIORITY | CONCURRENT] [LOCAL] INFILE starts here, + * without a dialect or in MySQL and MariaDB. BigQuery's LOAD DATA continues with INTO or + * OVERWRITE and DuckDB's or PostgreSQL's LOAD with a name, so their readings, including an + * extension named xml, stay unchanged. + */ + private boolean isMySqlLoadDataAhead() { + int format = getToken(2).kind; + String dialect = getAsString(Feature.dialect); + if (getToken(1).kind != K_LOAD || format != K_DATA && format != K_XML + || dialect != null && !isMySqlDialect()) { + return false; + } + int offset = 3; + if (getToken(offset).kind == K_LOW_PRIORITY || isWordAt(offset, "CONCURRENT")) { + offset++; + } + if (getToken(offset).kind == K_LOCAL) { + offset++; + } + return isWordAt(offset, "INFILE"); + } + + private boolean isMySqlCharacterSetClauseAhead() { + return getToken(1).kind == K_CHARACTER && getToken(2).kind == K_SET + || isWordAt(1, "CHARSET"); + } + + /** + * The number of MySQL's IGNORE number {LINES | ROWS}. MySQL reads it as an integer of at most + * 2147483647, leading zeros aside, and reports a larger one as a syntax error. + */ + private static Long mySqlIgnoreCount(Token token) throws ParseException { + String digits = token.image.replaceFirst("^0+(?=.)", ""); + if (digits.length() > 10 || Long.parseLong(digits) > Integer.MAX_VALUE) { + throw new ParseException("IGNORE takes a number of at most 2147483647"); + } + return Long.valueOf(digits); + } + + private boolean isBigQueryWithConnectionAhead() { + return getToken(1).kind == K_WITH && "CONNECTION".equalsIgnoreCase(getToken(2).image); + } + /** * Whether NO_WRITE_TO_BINLOG or LOCAL modifies a MySQL partition operation. Followed by a * comma, INTO or the end of the statement, the word remains a partition name. @@ -5469,6 +5513,8 @@ Statement SingleStatement() : | LOOKAHEAD({ getToken(1).kind == K_PRAGMA }) stm = PragmaStatement() | + LOOKAHEAD({ isMySqlLoadDataAhead() }) stm = MySqlLoadDataStatement() + | LOOKAHEAD({ getToken(1).kind == K_INSTALL || (getToken(1).kind == K_LOAD && getToken(2).kind != K_DATA) || (getToken(1).kind == K_FORCE && getToken(2).kind == K_INSTALL) }) @@ -7469,7 +7515,7 @@ LoadDataStatement LoadDataStatement() #LoadDataStatement: List columnDefinitions = new ArrayList(); ColumnDefinition columnDefinition; ExpressionList options; - ObjectNames connectionName; + String connectionName; Table table; } { @@ -7494,13 +7540,181 @@ LoadDataStatement LoadDataStatement() #LoadDataStatement: { loadDataStatement.setWithPartitionColumns(true); } ] [ - LOOKAHEAD({ getToken(1).kind == K_WITH && "CONNECTION".equalsIgnoreCase(getToken(2).image) }) - connectionName=RelObjectNames() - { loadDataStatement.setConnectionName(String.join(".", connectionName.getNames())); } + LOOKAHEAD({ isBigQueryWithConnectionAhead() }) + connectionName=BigQueryWithConnection() { loadDataStatement.setConnectionName(connectionName); } ] { return loadDataStatement; } } +/** BigQuery's WITH CONNECTION connection_name of LOAD DATA and EXPORT DATA. */ +String BigQueryWithConnection(): +{ ObjectNames connectionName; } +{ + ContextualKeyword("CONNECTION") connectionName=RelObjectNames() + { return String.join(".", connectionName.getNames()); } +} + +/** + * MySQL's LOAD {DATA | XML} [LOW_PRIORITY | CONCURRENT] [LOCAL] INFILE 'file_name' + * [REPLACE | IGNORE] INTO TABLE tbl_name [PARTITION (names)] [CHARACTER SET charset_name] + * [ROWS IDENTIFIED BY 'tag'] [FIELDS ...] [LINES ...] [IGNORE number {LINES | ROWS}] + * [(col_name_or_user_var, ...)] [SET col_name = {expr | DEFAULT}, ...]. One grammar serves + * DATA and XML, as in the server. + * + * https://dev.mysql.com/doc/refman/8.4/en/load-data.html + * https://dev.mysql.com/doc/refman/8.4/en/load-xml.html + */ +MySqlLoadDataStatement MySqlLoadDataStatement(): +{ + MySqlLoadDataStatement statement = new MySqlLoadDataStatement(); + Token token; + Table table; + List partitions; + boolean charsetKeyword; + MySqlFileFormat fileFormat; + ExpressionList columns; + List updateSets; +} +{ + ( | { statement.setFormat(MySqlLoadDataStatement.Format.XML); } ) + [ + { statement.setLockOption(MySqlLoadDataStatement.LockOption.LOW_PRIORITY); } + | + LOOKAHEAD({ isKeywordAhead("CONCURRENT") }) ContextualKeyword("CONCURRENT") + { statement.setLockOption(MySqlLoadDataStatement.LockOption.CONCURRENT); } + ] + [ { statement.setLocal(true); } ] + ContextualKeyword("INFILE") token= { statement.setFileName(stringValue(token)); } + [ + { statement.setDuplicateHandling(CreateTable.DuplicateHandling.REPLACE); } + | + { statement.setDuplicateHandling(CreateTable.DuplicateHandling.IGNORE); } + ] + table=Table() { statement.setTable(table); } + [ "(" partitions=PartitionNamesList() ")" { statement.setPartitions(partitions); } ] + [ + LOOKAHEAD({ isMySqlCharacterSetClauseAhead() }) + charsetKeyword=MySqlCharacterSetKeyword() token=CharacterSetName() + { statement.setCharacterSet(token.image); statement.setUseCharsetKeyword(charsetKeyword); } + ] + [ token= { statement.setRowsIdentifiedBy(stringValue(token)); } ] + [ fileFormat=MySqlFileFormat() { statement.setFileFormat(fileFormat); } ] + [ + token= { statement.setIgnoreCount(mySqlIgnoreCount(token)); } + ( | { statement.setIgnoreUnit(MySqlLoadDataStatement.IgnoreUnit.ROWS); } ) + ] + [ LOOKAHEAD(1, "(") columns=MySqlLoadDataColumns() { statement.setColumns(columns); } ] + [ LOOKAHEAD(1, ) updateSets=MySqlLoadDataSets() { statement.setUpdateSets(updateSets); } ] + { return statement; } +} + +/** The column list of LOAD DATA: column names and user variables, possibly none. */ +ExpressionList MySqlLoadDataColumns(): +{ + ExpressionList columns = new ExpressionList(); + Expression column; +} +{ + "(" + [ + ( column=MySqlIntoVariable() | column=Column() ) { columns.add(column); } + ( "," ( column=MySqlIntoVariable() | column=Column() ) { columns.add(column); } )* + ] + ")" + { return linkElements(columns); } +} + +/** The SET clause of LOAD DATA: each assignment sets one column to an expression or DEFAULT. */ +List MySqlLoadDataSets(): +{ + List updateSets = new ArrayList(); + UpdateSet updateSet; + Column column; + Expression value; +} +{ + column=Column() "=" value=Expression() + { + updateSet = new UpdateSet(column, value); + linkElements(updateSet.getColumns()); + linkElements(updateSet.getValues()); + updateSets.add(updateSet); + } + ( + "," column=Column() "=" value=Expression() + { + updateSet = new UpdateSet(column, value); + linkElements(updateSet.getColumns()); + linkElements(updateSet.getValues()); + updateSets.add(updateSet); + } + )* + { return updateSets; } +} + +/** MySQL's CHARACTER SET or CHARSET before a character set name: true for CHARSET. */ +boolean MySqlCharacterSetKeyword(): +{ boolean charsetKeyword = false; } +{ + ( | ContextualKeyword("CHARSET") { charsetKeyword = true; } ) + { return charsetKeyword; } +} + +/** + * The FIELDS (or COLUMNS) and LINES clauses of MySQL's LOAD DATA and SELECT ... INTO OUTFILE. + * Each clause takes at least one subclause, in any order and possibly repeated, as in the + * server's grammar; the last value of a subclause wins. + */ +MySqlFileFormat MySqlFileFormat(): +{ + MySqlFileFormat fileFormat = new MySqlFileFormat(); + Token first = getToken(1); +} +{ + ( + ( + { fileFormat.setFieldsKeyword(MySqlFileFormat.FieldsKeyword.FIELDS); } + | + { fileFormat.setFieldsKeyword(MySqlFileFormat.FieldsKeyword.COLUMNS); } + ) + ( LOOKAHEAD(1) MySqlFieldsSubclause(fileFormat) )+ + [ LOOKAHEAD(1, ) MySqlLinesClause(fileFormat) ] + | + MySqlLinesClause(fileFormat) + ) + { linkAST(fileFormat, first); return fileFormat; } +} + +void MySqlFieldsSubclause(MySqlFileFormat fileFormat): +{ Token token; } +{ + ( + token= { fileFormat.setFieldsTerminatedBy(stringValue(token)); } + | + // OPTIONALLY stays set when a later ENCLOSED BY replaces the character, as in the server + token= + { fileFormat.setFieldsOptionallyEnclosed(true); fileFormat.setFieldsEnclosedBy(stringValue(token)); } + | + token= { fileFormat.setFieldsEnclosedBy(stringValue(token)); } + | + token= { fileFormat.setFieldsEscapedBy(stringValue(token)); } + ) +} + +void MySqlLinesClause(MySqlFileFormat fileFormat): +{ Token token; } +{ + + ( + LOOKAHEAD(1) + ( + token= { fileFormat.setLinesStartingBy(stringValue(token)); } + | + token= { fileFormat.setLinesTerminatedBy(stringValue(token)); } + ) + )+ +} + /** * BigQuery's EXPORT DATA [WITH CONNECTION connection] OPTIONS (option_list) AS query. */ @@ -7508,15 +7722,14 @@ ExportDataStatement ExportDataStatement() #ExportDataStatement: { ExportDataStatement exportDataStatement = new ExportDataStatement(); ExpressionList options; - ObjectNames connectionName; + String connectionName; Select select; } { [ - LOOKAHEAD({ getToken(1).kind == K_WITH && "CONNECTION".equalsIgnoreCase(getToken(2).image) }) - connectionName=RelObjectNames() - { exportDataStatement.setConnectionName(String.join(".", connectionName.getNames())); } + LOOKAHEAD({ isBigQueryWithConnectionAhead() }) + connectionName=BigQueryWithConnection() { exportDataStatement.setConnectionName(connectionName); } ] [ LOOKAHEAD({ isKeywordAhead("OPTIONS") }) @@ -11273,6 +11486,7 @@ List IntoClause(): MySqlSelectIntoClause MySqlSelectIntoClause(MySqlSelectIntoClause.Position position): { MySqlSelectIntoClause intoClause = new MySqlSelectIntoClause().withPosition(position); + Token first = getToken(1); Token token; ExpressionList variables = new ExpressionList(); UserVariable variable; @@ -11295,6 +11509,7 @@ MySqlSelectIntoClause MySqlSelectIntoClause(MySqlSelectIntoClause.Position posit { intoClause.setType(MySqlSelectIntoClause.Type.VARIABLES); intoClause.setVariables(linkElements(variables)); } ) { + linkAST(intoClause, first); return intoClause; } } @@ -11342,106 +11557,26 @@ MySqlProcedureAnalyse MySqlProcedureAnalyse(): } } +/** + * The rest of INTO OUTFILE 'file_name': [CHARACTER SET charset_name] and the FIELDS and LINES + * clauses that LOAD DATA shares. + */ void MySqlSelectIntoOutfileTail(MySqlSelectIntoClause intoClause): { Token token; + boolean charsetKeyword; + MySqlFileFormat fileFormat; } { - ( - LOOKAHEAD( ) - - (token= | token=) - { intoClause.setCharacterSet(token.image); } - ( - LOOKAHEAD(1, ( | )) - MySqlSelectIntoFieldsClause(intoClause) - [ LOOKAHEAD(1, ) MySqlSelectIntoLinesClause(intoClause) ] - | - LOOKAHEAD(1, ) - MySqlSelectIntoLinesClause(intoClause) - | - { } - ) - | - LOOKAHEAD(1, ( | )) - MySqlSelectIntoFieldsClause(intoClause) - [ LOOKAHEAD(1, ) MySqlSelectIntoLinesClause(intoClause) ] - | - LOOKAHEAD(1, ) - MySqlSelectIntoLinesClause(intoClause) - | - { } - ) -} - -void MySqlSelectIntoFieldsClause(MySqlSelectIntoClause intoClause): -{ - Token token; -} -{ - ( - { intoClause.setFieldsKeyword(MySqlSelectIntoClause.FieldsKeyword.FIELDS); } - | { intoClause.setFieldsKeyword(MySqlSelectIntoClause.FieldsKeyword.COLUMNS); } - ) - ( - LOOKAHEAD( ) - token= - { intoClause.setFieldsTerminatedBy(stringValue(token)); } - ( - LOOKAHEAD(1, ( | )) - [ { intoClause.setFieldsOptionallyEnclosed(true); } ] - token= - { intoClause.setFieldsEnclosedBy(stringValue(token)); } - [ LOOKAHEAD( ) - token= - { intoClause.setFieldsEscapedBy(stringValue(token)); } - ] - | - LOOKAHEAD( ) - token= - { intoClause.setFieldsEscapedBy(stringValue(token)); } - | - { } - ) - | - LOOKAHEAD(1, ( | )) - [ { intoClause.setFieldsOptionallyEnclosed(true); } ] - token= - { intoClause.setFieldsEnclosedBy(stringValue(token)); } - [ LOOKAHEAD( ) - token= - { intoClause.setFieldsEscapedBy(stringValue(token)); } - ] - | - LOOKAHEAD( ) - token= - { intoClause.setFieldsEscapedBy(stringValue(token)); } - | - { } - ) -} - -void MySqlSelectIntoLinesClause(MySqlSelectIntoClause intoClause): -{ - Token token; -} -{ - - ( - LOOKAHEAD( ) - token= - { intoClause.setLinesStartingBy(stringValue(token)); } - [ LOOKAHEAD( ) - token= - { intoClause.setLinesTerminatedBy(stringValue(token)); } - ] - | - LOOKAHEAD( ) - token= - { intoClause.setLinesTerminatedBy(stringValue(token)); } - | - { } - ) + [ + LOOKAHEAD({ isMySqlCharacterSetClauseAhead() }) + charsetKeyword=MySqlCharacterSetKeyword() token=CharacterSetName() + { intoClause.setCharacterSet(token.image); intoClause.setUseCharsetKeyword(charsetKeyword); } + ] + [ + LOOKAHEAD(1, ( | | )) + fileFormat=MySqlFileFormat() { intoClause.setFileFormat(fileFormat); } + ] } FromItem ParenthesedFromItem(): diff --git a/src/site/sphinx/index.rst b/src/site/sphinx/index.rst index e0ff2d8a4d..e83bc837ef 100644 --- a/src/site/sphinx/index.rst +++ b/src/site/sphinx/index.rst @@ -164,6 +164,8 @@ One grammar covers the SQL standard plus all major RDBMS. Missing syntax gets ad - ``VACUUM`` · ``ANALYZE`` · ``CLUSTER`` · ``CHECKPOINT`` * - **MySQL locks** - ``LOCK TABLES`` · ``UNLOCK TABLES`` · ``LOCK INSTANCE FOR BACKUP`` · ``UNLOCK INSTANCE`` + * - **MySQL data loading** + - ``LOAD DATA`` · ``LOAD XML`` · ``SELECT … INTO OUTFILE`` with the same ``FIELDS`` and ``LINES`` clauses * - **MySQL account management** - ``CREATE``/``ALTER``/``RENAME USER`` · ``CREATE ROLE`` · ``SET ROLE`` · ``SET DEFAULT ROLE`` · ``SET PASSWORD`` * - **Tablespaces** diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 1fe9a87183..5350c24aa6 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -2606,6 +2606,48 @@ Table visitors report the locked tables but not their aliases. Validators report three, MariaDB the first two, and PostgreSQL and Oracle ``lock``. Feature analysis reports ``MODIFIES_TRANSACTION``. +MySQL LOAD DATA and LOAD XML +---------------------------- + +MySQL and MariaDB `LOAD DATA `_ and +`LOAD XML `_ parse to +``MySqlLoadDataStatement``; BigQuery's ``LOAD DATA {INTO | OVERWRITE}`` remains +``LoadDataStatement``. ``getFormat()`` is ``DATA`` or ``XML``. As in the server's grammar, +both forms take every clause: ``getLockOption()`` (``LOW_PRIORITY`` or ``CONCURRENT``), +``isLocal()``, ``getFileName()``, ``getDuplicateHandling()`` (``REPLACE`` or ``IGNORE``, the +enum of ``CREATE TABLE … SELECT``), ``getTable()``, ``getPartitions()``, +``getCharacterSet()`` with ``isUseCharsetKeyword()`` for the ``CHARSET`` spelling, +``getRowsIdentifiedBy()``, ``getFileFormat()``, ``getIgnoreCount()`` with ``getIgnoreUnit()`` +(``LINES`` or ``ROWS``), ``getColumns()`` (columns and ``UserVariable`` targets, empty for +``()``) and ``getUpdateSets()`` for the ``SET`` clause. + +.. code-block:: java + + MySqlLoadDataStatement load = (MySqlLoadDataStatement) CCJSqlParserUtil.parse( + "LOAD DATA LOCAL INFILE 'a.csv' INTO TABLE t FIELDS TERMINATED BY ',' " + + "IGNORE 1 LINES (a, @b) SET c = @b + 1"); + load.getFileFormat().getFieldsTerminatedBy().getValue(); // , + load.getUpdateSets().get(0).getValue(0); // @b + 1 + +``MySqlFileFormat`` holds the ``{FIELDS | COLUMNS}`` and ``LINES`` clauses, which +``SELECT … INTO OUTFILE`` shares: ``MySqlSelectIntoClause.getFileFormat()`` returns it, and the +clause's earlier getters and setters, such as ``getFieldsTerminatedBy()``, read and write it. +Each clause needs at least one subclause, so a bare ``FIELDS`` or ``LINES``, which MySQL +rejects, is now a parse error. The subclauses may come in any order and repeat, as MySQL +allows; like the server, the format keeps the last value of each and prints them in the +documented order. ``INTO OUTFILE`` also takes ``CHARSET`` and a quoted character set name. + +The MySQL and MariaDB dialects read these forms, and so does the parser without a dialect: +``INFILE`` after ``LOAD DATA`` or ``LOAD XML`` and the optional ``LOW_PRIORITY``, +``CONCURRENT`` and ``LOCAL`` tells them from BigQuery's ``LOAD DATA INTO`` and from DuckDB's +and PostgreSQL's ``LOAD name``, so ``LOAD xml`` still loads an extension named ``xml``. The +other dialects keep rejecting the MySQL forms. ``INFILE`` and ``CONCURRENT`` remain ordinary +identifiers. + +Table visitors report the target table and the tables of subqueries in the ``SET`` clause. +Validators report the ``loadData`` feature, which the MySQL and MariaDB capabilities now +enable. Feature analysis reports ``MODIFIES_DATA``. + MySQL account management statements ----------------------------------- diff --git a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java index 25dd26d38d..810510beb0 100644 --- a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java +++ b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java @@ -149,7 +149,14 @@ public void testStatementsTheCorpusLacks() throws JSQLParserException { "CHECKPOINT", "DROP CAST IF EXISTS (int[] AS public.t) CASCADE", "DROP OPERATOR public.=== (integer, integer), ~ (NONE, bigint)", - "DROP TRANSFORM FOR public.hstore LANGUAGE plpython3u"}) { + "DROP TRANSFORM FOR public.hstore LANGUAGE plpython3u", + "LOAD DATA LOCAL INFILE 'a' REPLACE INTO TABLE db1.t PARTITION (p0) CHARSET latin1" + + " FIELDS TERMINATED BY ',' LINES TERMINATED BY ';' IGNORE 1 LINES" + + " (a, @b) SET c = @b + 1, d = DEFAULT", + "LOAD XML INFILE 'a' INTO TABLE t ROWS IDENTIFIED BY '' LINES STARTING BY 'x' ()", + "SELECT a INTO OUTFILE '/tmp/o' COLUMNS ESCAPED BY '^' FROM t", + "SELECT a FROM t INTO OUTFILE '/tmp/o' CHARACTER SET 'utf8mb4'" + + " LINES TERMINATED BY ';'"}) { new Walk(sql, sql, problems).check(CCJSqlParserUtil.parseStatements(sql)); } String block = "BEGIN x := 1; NULL; foo(1); END;"; diff --git a/src/test/java/net/sf/jsqlparser/statement/MySqlFileFormatTest.java b/src/test/java/net/sf/jsqlparser/statement/MySqlFileFormatTest.java new file mode 100644 index 0000000000..68e8fad265 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/MySqlFileFormatTest.java @@ -0,0 +1,207 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.ASTNodeAccess; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.Node; +import net.sf.jsqlparser.statement.load.MySqlLoadDataStatement; +import net.sf.jsqlparser.statement.select.MySqlSelectIntoClause; +import net.sf.jsqlparser.statement.select.Select; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class MySqlFileFormatTest { + private static final Consumer MYSQL = parser -> parser.withDialect(Dialect.MYSQL); + private static final Consumer NO_DIALECT = parser -> { + }; + + @ParameterizedTest + @ValueSource(strings = { + "SELECT a FROM t INTO OUTFILE '/tmp/o' CHARSET utf8mb4 FIELDS TERMINATED BY ','", + "SELECT a FROM t INTO OUTFILE '/tmp/o' CHARACTER SET 'utf8mb4' " + + "LINES TERMINATED BY '\\n'", + "SELECT a FROM t INTO OUTFILE '/tmp/o' CHARACTER SET binary", + "SELECT a FROM t INTO OUTFILE '/tmp/o' COLUMNS ENCLOSED BY '\"' LINES STARTING BY 'x'", + "SELECT a INTO OUTFILE '/tmp/o' FIELDS ESCAPED BY '^' LINES STARTING BY '>' " + + "TERMINATED BY ';' FROM t"}) + void parsesIntoOutfileClauses(String sql) throws JSQLParserException { + for (Consumer dialect : List.of(MYSQL, NO_DIALECT)) { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, dialect); + assertEquals(sql, deparse(statement)); + } + } + + @Test + void intoOutfileAcceptsSubclausesInAnyOrder() throws JSQLParserException { + String sql = "SELECT a INTO OUTFILE '/tmp/o' FIELDS ESCAPED BY '^' TERMINATED BY ',' " + + "TERMINATED BY ';' LINES TERMINATED BY '\\n' STARTING BY 'x' FROM t"; + String expected = "SELECT a INTO OUTFILE '/tmp/o' FIELDS TERMINATED BY ';' ESCAPED BY '^' " + + "LINES STARTING BY 'x' TERMINATED BY '\\n' FROM t"; + for (Consumer dialect : List.of(MYSQL, NO_DIALECT)) { + Statement statement = CCJSqlParserUtil.parse(sql, dialect); + assertEquals(expected, statement.toString()); + assertEquals(expected, deparse(statement)); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT a INTO OUTFILE '/tmp/o' FIELDS FROM t", + "SELECT a INTO OUTFILE '/tmp/o' LINES FROM t", + "SELECT a INTO OUTFILE '/tmp/o' COLUMNS LINES TERMINATED BY 'x' FROM t", + "SELECT a FROM t INTO OUTFILE '/tmp/o' FIELDS", + "SELECT a FROM t INTO OUTFILE '/tmp/o' LINES", + "SELECT a FROM t INTO OUTFILE '/tmp/o' FIELDS TERMINATED BY ',' CHARSET latin1", + "SELECT a FROM t INTO OUTFILE '/tmp/o' LINES TERMINATED BY 'x' FIELDS ESCAPED BY '^'"}) + void rejectsBareOrMisplacedClauses(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MYSQL)); + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + } + + @Test + void outfileGettersReadTheSharedFormat() throws JSQLParserException { + MySqlSelectIntoClause into = into("SELECT a FROM t INTO OUTFILE '/tmp/o' CHARSET latin1 " + + "COLUMNS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '^' " + + "LINES STARTING BY 'x' TERMINATED BY ';'"); + MySqlFileFormat format = into.getFileFormat(); + + assertEquals("latin1", into.getCharacterSet()); + assertTrue(into.isUseCharsetKeyword()); + assertEquals(MySqlSelectIntoClause.FieldsKeyword.COLUMNS, into.getFieldsKeyword()); + assertEquals(MySqlFileFormat.FieldsKeyword.COLUMNS, format.getFieldsKeyword()); + assertSame(format.getFieldsTerminatedBy(), into.getFieldsTerminatedBy()); + assertTrue(into.isFieldsOptionallyEnclosed()); + assertSame(format.getFieldsEnclosedBy(), into.getFieldsEnclosedBy()); + assertSame(format.getFieldsEscapedBy(), into.getFieldsEscapedBy()); + assertSame(format.getLinesStartingBy(), into.getLinesStartingBy()); + assertSame(format.getLinesTerminatedBy(), into.getLinesTerminatedBy()); + assertTrue(into.hasFieldsClause()); + assertTrue(into.hasLinesClause()); + + MySqlSelectIntoClause plain = into("SELECT a FROM t INTO OUTFILE '/tmp/o'"); + assertNull(plain.getFileFormat()); + assertNull(plain.getFieldsKeyword()); + assertNull(plain.getFieldsTerminatedBy()); + assertFalse(plain.isFieldsOptionallyEnclosed()); + assertNull(plain.getLinesTerminatedBy()); + assertFalse(plain.hasFieldsClause()); + assertFalse(plain.hasLinesClause()); + } + + @Test + void loadDataAndIntoOutfileShareTheFormat() throws JSQLParserException { + String clauses = "FIELDS TERMINATED BY ',' LINES TERMINATED BY '\\n'"; + MySqlFileFormat outfile = + into("SELECT a FROM t INTO OUTFILE '/tmp/o' " + clauses).getFileFormat(); + MySqlFileFormat load = ((MySqlLoadDataStatement) CCJSqlParserUtil + .parse("LOAD DATA INFILE '/tmp/o' INTO TABLE t " + clauses)).getFileFormat(); + assertEquals(clauses, outfile.toString()); + assertEquals(clauses, load.toString()); + } + + @Test + void legacySettersWriteTheSharedFormat() { + MySqlSelectIntoClause into = new MySqlSelectIntoClause(); + into.setType(MySqlSelectIntoClause.Type.OUTFILE); + into.setFileName(new StringValue("/tmp/o")); + assertNull(into.getFileFormat()); + assertEquals("INTO OUTFILE '/tmp/o'", into.toString()); + + // OPTIONALLY set before the character it applies to still renders + into.setFieldsOptionallyEnclosed(true); + into.setFieldsEnclosedBy(new StringValue("\"")); + into.setFieldsKeyword(MySqlSelectIntoClause.FieldsKeyword.COLUMNS); + into.setLinesTerminatedBy(new StringValue(";")); + into.setCharacterSet("utf8mb4"); + assertEquals(MySqlFileFormat.FieldsKeyword.COLUMNS, + into.getFileFormat().getFieldsKeyword()); + assertEquals("INTO OUTFILE '/tmp/o' CHARACTER SET utf8mb4 COLUMNS OPTIONALLY ENCLOSED BY " + + "'\"' LINES TERMINATED BY ';'", into.toString()); + + into.setFieldsKeyword(null); + into.setUseCharsetKeyword(true); + assertNull(into.getFieldsKeyword()); + assertEquals("INTO OUTFILE '/tmp/o' CHARSET utf8mb4 FIELDS OPTIONALLY ENCLOSED BY '\"' " + + "LINES TERMINATED BY ';'", into.toString()); + + into.withFileFormat(new MySqlFileFormat().withFieldsEscapedBy(new StringValue("^"))); + assertEquals("^", into.getFieldsEscapedBy().getValue()); + assertNull(into.getFieldsEnclosedBy()); + assertEquals("INTO OUTFILE '/tmp/o' CHARSET utf8mb4 FIELDS ESCAPED BY '^'", + into.toString()); + } + + @Test + void rendersEachClauseOnlyWhenSet() { + MySqlFileFormat format = new MySqlFileFormat(); + assertTrue(format.isEmpty()); + assertEquals("", format.toString()); + + format.setLinesStartingBy(new StringValue("x")); + assertFalse(format.hasFieldsClause()); + assertEquals("LINES STARTING BY 'x'", format.toString()); + + format.withFieldsEnclosedBy(new StringValue("\"")).withFieldsOptionallyEnclosed(false) + .withLinesStartingBy(null).withLinesTerminatedBy(new StringValue(";")); + assertEquals("FIELDS ENCLOSED BY '\"' LINES TERMINATED BY ';'", format.toString()); + + // a spelling without a subclause renders as before, though MySQL rejects it + MySqlFileFormat keywordOnly = + new MySqlFileFormat().withFieldsKeyword(MySqlFileFormat.FieldsKeyword.FIELDS); + assertTrue(keywordOnly.hasFieldsClause()); + assertEquals("FIELDS", keywordOnly.toString()); + } + + @Test + void nodesSpanTheIntoClauseAndItsFormat() throws JSQLParserException { + String sql = "SELECT a INTO OUTFILE '/tmp/o' CHARSET latin1 FIELDS TERMINATED BY ',' " + + "LINES TERMINATED BY ';' FROM t"; + MySqlSelectIntoClause into = into(sql); + assertEquals("INTO OUTFILE '/tmp/o' CHARSET latin1 FIELDS TERMINATED BY ',' " + + "LINES TERMINATED BY ';'", text(sql, into)); + assertEquals("FIELDS TERMINATED BY ',' LINES TERMINATED BY ';'", + text(sql, into.getFileFormat())); + assertEquals("';'", text(sql, into.getLinesTerminatedBy())); + } + + private static MySqlSelectIntoClause into(String sql) throws JSQLParserException { + Select select = (Select) assertSqlCanBeParsedAndDeparsed(sql, false, MYSQL); + return select.getPlainSelect().getMySqlSelectIntoClause(); + } + + private static String deparse(Statement statement) { + StringBuilder builder = new StringBuilder(); + statement.accept(new StatementDeParser(builder), null); + return builder.toString(); + } + + private static String text(String sql, ASTNodeAccess access) { + Node node = access.getASTNode(); + return sql.substring(node.jjtGetFirstToken().absoluteBegin - 1, + node.jjtGetLastToken().absoluteEnd - 1); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/load/MySqlLoadDataStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/load/MySqlLoadDataStatementTest.java new file mode 100644 index 0000000000..b8f2df58f0 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/load/MySqlLoadDataStatementTest.java @@ -0,0 +1,454 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.load; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.expression.UserVariable; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.parser.ASTNodeAccess; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.Node; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.ExtensionStatement; +import net.sf.jsqlparser.statement.MySqlFileFormat; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitorAdapter; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.select.FromItemVisitorAdapter; +import net.sf.jsqlparser.statement.update.UpdateSet; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class MySqlLoadDataStatementTest { + private static final Consumer MYSQL = parser -> parser.withDialect(Dialect.MYSQL); + private static final Consumer MARIADB = + parser -> parser.withDialect(Dialect.MARIADB); + private static final Consumer NO_DIALECT = parser -> { + }; + + @ParameterizedTest + @ValueSource(strings = { + "LOAD DATA INFILE '/tmp/a.csv' INTO TABLE t", + "LOAD DATA LOCAL INFILE 'a.csv' REPLACE INTO TABLE db1.t PARTITION (p0, p1) " + + "CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' " + + "LINES STARTING BY 'x' TERMINATED BY '\\n' IGNORE 1 LINES (a, @b) " + + "SET c = @b + 1, d = DEFAULT", + "LOAD DATA LOW_PRIORITY INFILE 'a' IGNORE INTO TABLE t COLUMNS TERMINATED BY '\\t' " + + "IGNORE 2 ROWS", + "LOAD DATA CONCURRENT INFILE 'a' INTO TABLE t LINES TERMINATED BY '\\r\\n'", + "LOAD DATA CONCURRENT LOCAL INFILE 'a' INTO TABLE t", + "LOAD DATA LOW_PRIORITY LOCAL INFILE 'a' IGNORE INTO TABLE t", + "LOAD DATA INFILE 'a' INTO TABLE t FIELDS ENCLOSED BY '\"' ESCAPED BY '\\\\'", + "LOAD DATA INFILE 'a' INTO TABLE t FIELDS ESCAPED BY '' LINES STARTING BY ''", + "LOAD DATA INFILE 'a' INTO TABLE t LINES STARTING BY 'x'", + "LOAD DATA INFILE 'a' INTO TABLE t CHARSET latin1", + "LOAD DATA INFILE 'a' INTO TABLE t CHARACTER SET 'utf8mb4'", + "LOAD DATA INFILE 'a' INTO TABLE t CHARACTER SET binary", + "LOAD DATA INFILE 'a' INTO TABLE t ()", + "LOAD DATA INFILE 'a' INTO TABLE db1.t (t.a, db1.t.b, @c, @`d e`)", + "LOAD DATA INFILE 'a' INTO TABLE t SET a = (SELECT MAX(b) FROM u), " + + "c = CURRENT_TIMESTAMP", + "LOAD DATA INFILE 'a' INTO TABLE t IGNORE 0 LINES", + "LOAD DATA INFILE 'a' INTO TABLE t IGNORE 2147483647 LINES", + "LOAD DATA INFILE 'a' INTO TABLE t ROWS IDENTIFIED BY ''", + "LOAD XML LOCAL INFILE 'a.xml' INTO TABLE t ROWS IDENTIFIED BY ''", + "LOAD XML INFILE 'a.xml' REPLACE INTO TABLE t CHARACTER SET latin1 " + + "ROWS IDENTIFIED BY '' IGNORE 1 ROWS (a, @x) SET b = @x", + "LOAD XML LOW_PRIORITY INFILE 'a.xml' IGNORE INTO TABLE t PARTITION (p0)", + "LOAD XML INFILE 'a.xml' INTO TABLE t FIELDS TERMINATED BY ',' " + + "LINES TERMINATED BY '\\n'"}) + void parsesMySqlFormsWithAndWithoutDialect(String sql) throws JSQLParserException { + for (Consumer dialect : List.of(MYSQL, MARIADB, NO_DIALECT)) { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, dialect); + assertInstanceOf(MySqlLoadDataStatement.class, statement); + assertEquals(sql, deparse(statement)); + assertEquals(sql, CCJSqlParserUtil.parse(statement.toString(), dialect).toString()); + } + } + + @Test + void keepsEveryClause() throws JSQLParserException { + MySqlLoadDataStatement load = parse("LOAD DATA LOW_PRIORITY LOCAL INFILE 'a.csv' " + + "REPLACE INTO TABLE db1.t PARTITION (p0, p1) CHARSET 'utf8mb4' " + + "COLUMNS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '^' " + + "LINES STARTING BY 'x' TERMINATED BY ';' IGNORE 3 ROWS (a, @b, t.c) " + + "SET d = @b + 1, e = DEFAULT", MYSQL); + + assertEquals(MySqlLoadDataStatement.Format.DATA, load.getFormat()); + assertEquals(MySqlLoadDataStatement.LockOption.LOW_PRIORITY, load.getLockOption()); + assertTrue(load.isLocal()); + assertEquals("a.csv", load.getFileName().getValue()); + assertEquals(CreateTable.DuplicateHandling.REPLACE, load.getDuplicateHandling()); + assertEquals("db1.t", load.getTable().getFullyQualifiedName()); + assertEquals(Arrays.asList("p0", "p1"), load.getPartitions()); + assertEquals("'utf8mb4'", load.getCharacterSet()); + assertTrue(load.isUseCharsetKeyword()); + assertNull(load.getRowsIdentifiedBy()); + + MySqlFileFormat format = load.getFileFormat(); + assertEquals(MySqlFileFormat.FieldsKeyword.COLUMNS, format.getFieldsKeyword()); + assertEquals(",", format.getFieldsTerminatedBy().getValue()); + assertTrue(format.isFieldsOptionallyEnclosed()); + assertEquals("\"", format.getFieldsEnclosedBy().getValue()); + assertEquals("^", format.getFieldsEscapedBy().getValue()); + assertEquals("x", format.getLinesStartingBy().getValue()); + assertEquals(";", format.getLinesTerminatedBy().getValue()); + + assertEquals(3L, load.getIgnoreCount()); + assertEquals(MySqlLoadDataStatement.IgnoreUnit.ROWS, load.getIgnoreUnit()); + assertEquals(3, load.getColumns().size()); + assertInstanceOf(Column.class, load.getColumns().get(0)); + assertEquals("b", ((UserVariable) load.getColumns().get(1)).getName()); + assertEquals("t.c", ((Column) load.getColumns().get(2)).getFullyQualifiedName()); + assertEquals(2, load.getUpdateSets().size()); + assertEquals("d", load.getUpdateSets().get(0).getColumn(0).getColumnName()); + assertEquals("@b + 1", load.getUpdateSets().get(0).getValue(0).toString()); + assertEquals("DEFAULT", load.getUpdateSets().get(1).getValue(0).toString()); + } + + @Test + void readsLoadXmlAndItsRowTag() throws JSQLParserException { + MySqlLoadDataStatement load = parse( + "LOAD XML CONCURRENT INFILE 'a.xml' IGNORE INTO TABLE t ROWS IDENTIFIED BY ''", + MYSQL); + assertEquals(MySqlLoadDataStatement.Format.XML, load.getFormat()); + assertEquals(MySqlLoadDataStatement.LockOption.CONCURRENT, load.getLockOption()); + assertFalse(load.isLocal()); + assertEquals(CreateTable.DuplicateHandling.IGNORE, load.getDuplicateHandling()); + assertEquals("", load.getRowsIdentifiedBy().getValue()); + assertNull(load.getPartitions()); + assertNull(load.getCharacterSet()); + assertNull(load.getFileFormat()); + assertNull(load.getIgnoreCount()); + assertNull(load.getColumns()); + assertNull(load.getUpdateSets()); + } + + @Test + void keepsAnEmptyColumnListApartFromNone() throws JSQLParserException { + assertTrue(parse("LOAD DATA INFILE 'a' INTO TABLE t ()", MYSQL).getColumns().isEmpty()); + assertNull(parse("LOAD DATA INFILE 'a' INTO TABLE t", MYSQL).getColumns()); + } + + @Test + void acceptsSubclausesInAnyOrderAndKeepsTheLastValue() throws JSQLParserException { + // the server merges repeated subclauses, the last one winning, and keeps OPTIONALLY + assertNormalized( + "LOAD DATA INFILE 'a' INTO TABLE t COLUMNS OPTIONALLY ENCLOSED BY '\"' " + + "TERMINATED BY ','", + "LOAD DATA INFILE 'a' INTO TABLE t COLUMNS TERMINATED BY ',' " + + "OPTIONALLY ENCLOSED BY '\"'"); + assertNormalized( + "LOAD DATA INFILE 'a' INTO TABLE t FIELDS ESCAPED BY '^' TERMINATED BY ',' " + + "TERMINATED BY ';' LINES TERMINATED BY '\\n' STARTING BY '>'", + "LOAD DATA INFILE 'a' INTO TABLE t FIELDS TERMINATED BY ';' ESCAPED BY '^' " + + "LINES STARTING BY '>' TERMINATED BY '\\n'"); + assertNormalized( + "LOAD DATA INFILE 'a' INTO TABLE t FIELDS OPTIONALLY ENCLOSED BY '\"' " + + "ENCLOSED BY '|'", + "LOAD DATA INFILE 'a' INTO TABLE t FIELDS OPTIONALLY ENCLOSED BY '|'"); + assertNormalized("load data local infile 'a' replace into table t fields terminated by " + + "',' ignore 1 lines (a) set b = 1", + "LOAD DATA LOCAL INFILE 'a' REPLACE INTO TABLE t FIELDS TERMINATED BY ',' " + + "IGNORE 1 LINES (a) SET b = 1"); + assertNormalized("LOAD DATA INFILE 'a' INTO TABLE t IGNORE 007 LINES", + "LOAD DATA INFILE 'a' INTO TABLE t IGNORE 7 LINES"); + } + + @Test + void readsDoubleQuotedStringsInMySql() throws JSQLParserException { + MySqlLoadDataStatement load = parse( + "LOAD DATA INFILE \"a.csv\" INTO TABLE t FIELDS ENCLOSED BY \"'\"", MYSQL); + assertEquals("a.csv", load.getFileName().getValue()); + assertEquals("'", load.getFileFormat().getFieldsEnclosedBy().getValue()); + } + + @ParameterizedTest + @ValueSource(strings = { + "LOAD DATA INFILE 'a' INTO TABLE t FIELDS", + "LOAD DATA INFILE 'a' INTO TABLE t LINES", + "LOAD DATA INFILE 'a' INTO TABLE t COLUMNS", + "LOAD DATA INFILE 'a' INTO TABLE t FIELDS LINES TERMINATED BY '\\n'", + "LOAD DATA INFILE 'a' INTO TABLE t LINES TERMINATED BY '\\n' FIELDS TERMINATED BY ','", + "LOAD DATA INFILE 'a' INTO TABLE t CHARACTER SET utf8mb4 PARTITION (p0)", + "LOAD DATA INFILE 'a' INTO TABLE t FIELDS TERMINATED BY ',' ROWS IDENTIFIED BY ''", + "LOAD DATA INFILE 'a' INTO TABLE t IGNORE 1 LINES FIELDS TERMINATED BY ','", + "LOAD DATA INFILE 'a' INTO TABLE t SET a = 1 (a)", + "LOAD DATA INFILE 'a' INTO t", + "LOAD DATA 'a' INTO TABLE t", + "LOAD DATA LOCAL LOW_PRIORITY INFILE 'a' INTO TABLE t", + "LOAD DATA LOW_PRIORITY CONCURRENT INFILE 'a' INTO TABLE t", + "LOAD DATA INFILE 'a' REPLACE IGNORE INTO TABLE t", + "LOAD DATA INFILE 'a' INTO TABLE t IGNORE 1", + "LOAD DATA INFILE 'a' INTO TABLE t IGNORE 2147483648 LINES", + "LOAD DATA INFILE 'a' INTO TABLE t IGNORE 99999999999999999999 LINES", + "LOAD DATA INFILE 'a' INTO TABLE t IGNORE -1 LINES", + "LOAD DATA INFILE 'a' INTO TABLE t (a, @@b)", + "LOAD DATA INFILE 'a' INTO TABLE t SET @x = 1", + "LOAD DATA INFILE 'a' INTO TABLE t SET (a, b) = (1, 2)", + "LOAD DATA INFILE a INTO TABLE t", + "LOAD DATA INFILE 'a' INTO TABLE t FIELDS TERMINATED ','", + "LOAD DATA INFILE 'a' INTO TABLE t PARTITION ()", + "LOAD XML INFILE 'a' INTO TABLE t ROWS IDENTIFIED ''", + "LOAD DATA INFILE 'a' INTO TABLE t SET a = 1,", + "LOAD DATA INFILE 'a' INTO TABLE t (a,)"}) + void rejectsWhatMySqlRejects(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MYSQL)); + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + } + + @ParameterizedTest + @ValueSource(strings = {"LOAD DATA INFILE 'a' INTO TABLE t", + "LOAD XML INFILE 'a.xml' INTO TABLE t", + "LOAD DATA LOCAL INFILE 'a' INTO TABLE t"}) + void otherDialectsKeepRejectingMySqlForms(String sql) { + for (Dialect dialect : new Dialect[] {Dialect.POSTGRESQL, Dialect.ORACLE, + Dialect.SQLSERVER, Dialect.BIGQUERY}) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect)), + dialect.name()); + } + } + + @Test + void otherLoadStatementsKeepTheirReading() throws JSQLParserException { + for (Consumer dialect : List.of(MYSQL, NO_DIALECT)) { + // DuckDB's LOAD of an extension named xml or data stays an extension statement + ExtensionStatement xml = (ExtensionStatement) CCJSqlParserUtil.parse("LOAD xml", + dialect); + assertEquals("xml", xml.getExtensionName()); + assertInstanceOf(ExtensionStatement.class, + CCJSqlParserUtil.parse("LOAD httpfs", dialect)); + assertInstanceOf(LoadDataStatement.class, CCJSqlParserUtil.parse( + "LOAD DATA INTO ds.t FROM FILES (format = 'CSV')", dialect)); + } + ExtensionStatement library = (ExtensionStatement) CCJSqlParserUtil.parse( + "LOAD 'auto_explain'", parser -> parser.withDialect(Dialect.POSTGRESQL)); + assertEquals("'auto_explain'", library.getExtensionName()); + assertInstanceOf(LoadDataStatement.class, + CCJSqlParserUtil.parse("LOAD DATA OVERWRITE ds.t FROM FILES (format = 'CSV') " + + "WITH CONNECTION p.us.c", + parser -> parser.withDialect(Dialect.BIGQUERY))); + } + + @Test + void wordsRemainUsableAsNames() throws JSQLParserException { + // CONCURRENT, XML and CHARSET are names in MySQL too + for (String sql : new String[] {"CREATE TABLE concurrent (xml INT, charset INT)", + "SELECT concurrent, xml, charset FROM t"}) { + assertSqlCanBeParsedAndDeparsed(sql, false, MYSQL); + } + MySqlLoadDataStatement load = + parse("LOAD DATA INFILE 'a' INTO TABLE concurrent (xml, charset)", MYSQL); + assertEquals("concurrent", load.getTable().getName()); + + // MySQL reserves INFILE, but the parser adds no reserved word + for (String sql : new String[] {"CREATE TABLE infile (a INT)", "SELECT infile FROM t", + "LOAD DATA INFILE 'a' INTO TABLE infile (infile)"}) { + assertSqlCanBeParsedAndDeparsed(sql, false, NO_DIALECT); + } + } + + @Test + void endsTheStatementsAroundIt() throws JSQLParserException { + for (Consumer dialect : List.of(MYSQL, NO_DIALECT)) { + Statements statements = CCJSqlParserUtil.parseStatements( + "SELECT 1; LOAD DATA INFILE 'a' INTO TABLE t (a) SET b = 1; " + + "LOAD XML INFILE 'b' INTO TABLE u;\nLOAD xml; SELECT 2", + dialect); + assertEquals(List.of("PlainSelect", "MySqlLoadDataStatement", + "MySqlLoadDataStatement", "ExtensionStatement", "PlainSelect"), + statements.stream().map(statement -> statement.getClass().getSimpleName()) + .collect(Collectors.toList())); + } + } + + @Test + void isAStatementStartForUnsupportedStatements() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "FOO BAR; LOAD DATA INFILE 'a' INTO TABLE t; BAZ", + parser -> parser.withDialect(Dialect.MYSQL).withUnsupportedStatements(true)); + assertEquals(3, statements.size()); + assertInstanceOf(UnsupportedStatement.class, statements.get(0)); + assertInstanceOf(MySqlLoadDataStatement.class, statements.get(1)); + assertInstanceOf(UnsupportedStatement.class, statements.get(2)); + } + + @Test + void findsTheTargetAndTheTablesOfSubqueries() throws JSQLParserException { + Statement load = CCJSqlParserUtil.parse("LOAD DATA INFILE 'a' INTO TABLE db1.t (a, @b) " + + "SET c = (SELECT MAX(x) FROM u WHERE u.k = @b)"); + assertThat(new TablesNamesFinder().getTables(load)) + .containsExactlyInAnyOrder("db1.t", "u"); + assertThat(new TablesNamesFinder().getTables( + CCJSqlParserUtil.parse("LOAD XML INFILE 'a' INTO TABLE t"))).containsExactly("t"); + } + + @Test + void reportsDataFeatures() throws JSQLParserException { + Statement plain = CCJSqlParserUtil.parse("LOAD DATA INFILE 'a' INTO TABLE t"); + assertThat(plain.getFeatures().getCertain()).containsExactly(StmtFeature.MODIFIES_DATA); + + Statement subquery = CCJSqlParserUtil.parse( + "LOAD DATA INFILE 'a' INTO TABLE t SET c = (SELECT MAX(x) FROM u)"); + assertThat(subquery.getFeatures().getCertain()) + .contains(StmtFeature.MODIFIES_DATA, StmtFeature.READS_DATA); + } + + @Test + void adapterVisitsTheTableColumnsAndAssignments() throws JSQLParserException { + Statement load = CCJSqlParserUtil.parse( + "LOAD DATA INFILE 'a' INTO TABLE t (a, @b) SET c = @b + 1"); + Object context = new Object(); + List visited = new ArrayList<>(); + ExpressionVisitorAdapter expressions = new ExpressionVisitorAdapter() { + @Override + public Void visit(Column column, S value) { + visited.add(column.getColumnName()); + return null; + } + + @Override + public Void visit(UserVariable variable, S value) { + visited.add(variable.toString()); + return null; + } + + @Override + public Void visit(LongValue longValue, S value) { + visited.add(longValue.getStringValue()); + return null; + } + }; + FromItemVisitorAdapter tables = new FromItemVisitorAdapter() { + @Override + public Void visit(Table table, S value) { + visited.add("table " + table.getName()); + return null; + } + }; + load.accept(new StatementVisitorAdapter(expressions, null, null, tables, null, null), + context); + assertEquals(List.of("table t", "a", "@b", "c", "@b", "1"), visited); + assertNull(load.accept(new StatementVisitorAdapter<>(), null)); + } + + @Test + void nodesSpanTheStatementAndItsClauses() throws JSQLParserException { + String sql = "LOAD DATA INFILE 'a' INTO TABLE t CHARSET latin1 " + + "FIELDS TERMINATED BY ',' LINES TERMINATED BY ';' IGNORE 1 LINES (a, @b) " + + "SET c = @b"; + MySqlLoadDataStatement load = parse(sql, MYSQL); + assertEquals(sql, text(sql, load)); + assertEquals("'a'", text(sql, load.getFileName())); + assertEquals("t", text(sql, load.getTable())); + assertEquals("FIELDS TERMINATED BY ',' LINES TERMINATED BY ';'", + text(sql, load.getFileFormat())); + assertEquals("','", text(sql, load.getFileFormat().getFieldsTerminatedBy())); + assertEquals("a, @b", text(sql, load.getColumns())); + assertEquals("@b", text(sql, (ASTNodeAccess) load.getUpdateSets().get(0).getValues())); + + String xml = "LOAD XML INFILE 'a' INTO TABLE t LINES STARTING BY '<'"; + MySqlLoadDataStatement lines = parse(xml, MYSQL); + assertEquals("LINES STARTING BY '<'", text(xml, lines.getFileFormat())); + } + + @Test + void buildsStatementsInCode() { + MySqlLoadDataStatement load = new MySqlLoadDataStatement() + .withFileName(new StringValue("data.csv")) + .withTable(new Table("db1", "t")); + assertEquals("LOAD DATA INFILE 'data.csv' INTO TABLE db1.t", load.toString()); + + ExpressionList columns = new ExpressionList<>(); + columns.add(new Column("a")); + columns.add(new UserVariable("b")); + load.withFormat(MySqlLoadDataStatement.Format.XML) + .withLockOption(MySqlLoadDataStatement.LockOption.CONCURRENT) + .withLocal(true) + .withDuplicateHandling(CreateTable.DuplicateHandling.IGNORE) + .withPartitions(Arrays.asList("p0", "p1")) + .withCharacterSet("utf8mb4") + .withUseCharsetKeyword(true) + .withRowsIdentifiedBy(new StringValue("")) + .withFileFormat(new MySqlFileFormat() + .withFieldsTerminatedBy(new StringValue(",")) + .withLinesTerminatedBy(new StringValue("\\n"))) + .withIgnoreCount(1L) + .withIgnoreUnit(MySqlLoadDataStatement.IgnoreUnit.ROWS) + .withColumns(columns) + .withUpdateSets(List.of(new UpdateSet(new Column("c"), new UserVariable("b")))); + String expected = "LOAD XML CONCURRENT LOCAL INFILE 'data.csv' IGNORE INTO TABLE db1.t " + + "PARTITION (p0, p1) CHARSET utf8mb4 ROWS IDENTIFIED BY '' " + + "FIELDS TERMINATED BY ',' LINES TERMINATED BY '\\n' IGNORE 1 ROWS (a, @b) " + + "SET c = @b"; + assertEquals(expected, load.toString()); + assertEquals(expected, deparse(load)); + + load.setFileFormat(new MySqlFileFormat()); + load.setUseCharsetKeyword(false); + assertEquals("LOAD XML CONCURRENT LOCAL INFILE 'data.csv' IGNORE INTO TABLE db1.t " + + "PARTITION (p0, p1) CHARACTER SET utf8mb4 ROWS IDENTIFIED BY '' " + + "IGNORE 1 ROWS (a, @b) SET c = @b", load.toString()); + } + + private static void assertNormalized(String sql, String expected) throws JSQLParserException { + for (Consumer dialect : List.of(MYSQL, NO_DIALECT)) { + Statement statement = CCJSqlParserUtil.parse(sql, dialect); + assertEquals(expected, statement.toString()); + assertEquals(expected, deparse(statement)); + assertEquals(expected, CCJSqlParserUtil.parse(expected, dialect).toString()); + } + } + + private static MySqlLoadDataStatement parse(String sql, Consumer dialect) + throws JSQLParserException { + return (MySqlLoadDataStatement) assertSqlCanBeParsedAndDeparsed(sql, false, dialect); + } + + private static String deparse(Statement statement) { + StringBuilder builder = new StringBuilder(); + statement.accept(new StatementDeParser(builder), null); + return builder.toString(); + } + + private static String text(String sql, ASTNodeAccess access) { + Node node = access.getASTNode(); + return sql.substring(node.jjtGetFirstToken().absoluteBegin - 1, + node.jjtGetLastToken().absoluteEnd - 1); + } +} diff --git a/src/test/java/net/sf/jsqlparser/util/deparser/StructuredStatementVisitorTest.java b/src/test/java/net/sf/jsqlparser/util/deparser/StructuredStatementVisitorTest.java index b4f4232e11..76450f7a5b 100644 --- a/src/test/java/net/sf/jsqlparser/util/deparser/StructuredStatementVisitorTest.java +++ b/src/test/java/net/sf/jsqlparser/util/deparser/StructuredStatementVisitorTest.java @@ -30,7 +30,9 @@ class StructuredStatementVisitorTest { "ASSERT (1 = 1) AS 'expected'", "ASSERT EXISTS (SELECT 7 FROM users WHERE id = 8)", "EXPORT DATA OPTIONS(uri='gs://example/*.csv',format='CSV') AS SELECT 7 FROM users", - "EXPORT DATA OPTIONS(uri='gs://example/*.csv',format='CSV') AS WITH c AS (SELECT 7 AS id) SELECT id FROM c UNION ALL SELECT 8" + "EXPORT DATA OPTIONS(uri='gs://example/*.csv',format='CSV') AS WITH c AS (SELECT 7 AS id) SELECT id FROM c UNION ALL SELECT 8", + "LOAD DATA INTO ds.t OPTIONS (description = 'x') FROM FILES (format = 'CSV', skip_leading_rows = 1)", + "LOAD DATA INFILE 'a' INTO TABLE t FIELDS TERMINATED BY ',' (a, @b) SET c = @b + 1, d = 'x'" }) void defaultDeparserMatchesStatementOutput(String sql) throws Exception { Statement statement = CCJSqlParserUtil.parse(sql); @@ -68,6 +70,23 @@ void visitsExportOptionsAndQuery() throws Exception { "EXPORT DATA OPTIONS (uri = 'changed', format = 'changed') AS SELECT 107 FROM users WHERE id = 108"); } + @Test + void visitsBigQueryLoadOptions() throws Exception { + assertVisited( + "LOAD DATA INTO ds.t OPTIONS (description = 'x') FROM FILES (format = 'CSV', skip_leading_rows = 1)", + List.of("x", "CSV", "1"), + "LOAD DATA INTO ds.t OPTIONS (description = 'changed') FROM FILES (format = 'changed', skip_leading_rows = 101)"); + } + + @Test + void visitsMySqlLoadColumnsAndAssignments() throws Exception { + // the file name and the separators are literal syntax, as in SELECT ... INTO OUTFILE + assertVisited( + "LOAD DATA INFILE 'a' INTO TABLE t FIELDS TERMINATED BY ',' (a, @b) SET c = @b + 1, d = 'x'", + List.of("1", "x"), + "LOAD DATA INFILE 'a' INTO TABLE t FIELDS TERMINATED BY ',' (a, @b) SET c = @b + 101, d = 'changed'"); + } + private void assertVisited(String sql, List expectedVisits, String expectedOutput) throws Exception { List visited = new ArrayList<>(); diff --git a/src/test/java/net/sf/jsqlparser/util/validation/validator/LoadDataValidatorTest.java b/src/test/java/net/sf/jsqlparser/util/validation/validator/LoadDataValidatorTest.java new file mode 100644 index 0000000000..7da35b40d4 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/LoadDataValidatorTest.java @@ -0,0 +1,45 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.util.validation.validator; + +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.util.validation.ValidationTestAsserts; +import net.sf.jsqlparser.util.validation.feature.DatabaseType; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +import net.sf.jsqlparser.util.validation.feature.MariaDbVersion; +import net.sf.jsqlparser.util.validation.feature.MySqlVersion; +import net.sf.jsqlparser.util.validation.feature.PostgresqlVersion; +import org.junit.jupiter.api.Test; + +public class LoadDataValidatorTest extends ValidationTestAsserts { + + @Test + public void testLoadDataAllowedForMySqlAndMariaDb() throws JSQLParserException { + for (String sql : new String[] { + "LOAD DATA LOCAL INFILE 'a.csv' INTO TABLE t FIELDS TERMINATED BY ',' (a, @b) " + + "SET c = @b + 1", + "LOAD XML INFILE 'a.xml' INTO TABLE t ROWS IDENTIFIED BY ''"}) { + validateNoErrors(sql, 1, MySqlVersion.V8_0, MariaDbVersion.V10_5_4, + DatabaseType.MYSQL, DatabaseType.MARIADB); + } + } + + @Test + public void testLoadDataNotAllowedForPostgresql() throws JSQLParserException { + validate("LOAD DATA INFILE 'a.csv' INTO TABLE t", 1, 1, PostgresqlVersion.V14); + } + + @Test + public void testFeatureNotAllowed() throws JSQLParserException { + validateNotAllowed("LOAD DATA INFILE 'a.csv' INTO TABLE t", 1, 1, FeaturesAllowed.SELECT, + Feature.loadData); + } +} diff --git a/src/test/java/net/sf/jsqlparser/util/validation/validator/StatementValidatorTest.java b/src/test/java/net/sf/jsqlparser/util/validation/validator/StatementValidatorTest.java index 51d4717635..1fe76e8c7d 100644 --- a/src/test/java/net/sf/jsqlparser/util/validation/validator/StatementValidatorTest.java +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/StatementValidatorTest.java @@ -32,6 +32,7 @@ static Stream statementsOutsideSelect() { return Stream.of( Arguments.of("COPY t TO '/tmp/t.csv'", Feature.copy), Arguments.of("LOAD DATA INTO ds.t FROM FILES (format = 'CSV')", Feature.loadData), + Arguments.of("LOAD XML INFILE 'a.xml' INTO TABLE t", Feature.loadData), Arguments.of("EXPORT DATA OPTIONS (uri = 'gs://b/*.csv', format = 'CSV') " + "AS SELECT a FROM t", Feature.export), Arguments.of("ATTACH DATABASE '/tmp/x.db' AS x", Feature.attach),