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 extends Expression> 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),