Copilot commented on code in PR #4910:
URL: https://github.com/apache/solr/pull/4910#discussion_r4529239834


##########
solr/core/src/java/org/apache/solr/handler/loader/NDJsonLoader.java:
##########
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.handler.loader;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.Set;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.util.ContentStream;
+import org.eclipse.jetty.http.MimeTypes;
+import org.noggit.JSONParser;
+import org.noggit.JSONParser.ParseException;
+
+/**
+ * Loads documents in <a href="https://jsonlines.org/";>Newline Delimited 
JSON</a> (ND-JSON, also
+ * known as JSON Lines or JSONL) format; one JSON object per line, each 
representing a document to
+ * add.
+ *
+ * <p>Documents are mapped exactly as on the {@code /update/json/docs} path: a 
nested JSON object is
+ * flattened into dotted field names unless the {@code split} parameter 
declares its path to be a
+ * nested document. Every line is an implicit add; the other update commands 
are not recognized, so
+ * use request parameters such as {@code commit} or a separate request for 
those.
+ *
+ * <p>The only differences from that path are the restrictions the format 
itself implies, all
+ * enforced while streaming: the content must be UTF-8, every document sits on 
a line of its own,
+ * and {@code split} must start at the document root, since a line is already 
one document.
+ */
+public class NDJsonLoader extends JsonLoader {
+
+  /** The content types that select this loader. */
+  public static final Set<String> CONTENT_TYPES =
+      Set.of(
+          "application/x-ndjson",
+          "application/jsonl",
+          "application/jsonlines",
+          "application/x-jsonlines");
+
+  @Override
+  public void load(
+      SolrQueryRequest req,
+      SolrQueryResponse rsp,
+      ContentStream stream,
+      UpdateRequestProcessor processor)
+      throws Exception {
+    assertUtf8(stream);
+    super.load(req, rsp, stream, processor);
+  }
+
+  @Override
+  protected SingleThreadedJsonLoader createLoader(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor 
processor) {
+    return new SingleThreadedNDJsonLoader(req, rsp, processor);
+  }
+
+  /**
+   * NDJSON is defined as UTF-8, so a stream declaring any other charset is 
rejected rather than
+   * silently decoded. A stream that declares no charset is read as UTF-8.
+   */
+  private static void assertUtf8(ContentStream stream) {
+    String charset = 
MimeTypes.getCharsetFromContentType(stream.getContentType());
+    if (charset != null && 
!StandardCharsets.UTF_8.equals(Charset.forName(charset, null))) {
+      throw new SolrException(
+          SolrException.ErrorCode.UNSUPPORTED_MEDIA_TYPE,
+          "NDJSON must be UTF-8 encoded, but charset=" + charset + " was 
declared");
+    }
+  }
+
+  private static class SingleThreadedNDJsonLoader extends 
SingleThreadedJsonLoader {
+
+    private NDJsonParser ndJsonParser;
+
+    SingleThreadedNDJsonLoader(
+        SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor 
processor) {
+      super(req, rsp, processor);
+    }
+
+    /** As the base loader, but reporting a malformed document by line rather 
than by offset. */
+    @Override
+    public void load(
+        SolrQueryRequest req,
+        SolrQueryResponse rsp,
+        ContentStream stream,
+        UpdateRequestProcessor processor)
+        throws Exception {
+      // The charset is already known to be UTF-8, so the stream is decoded as 
such
+      try (Reader reader = new InputStreamReader(stream.getStream(), 
StandardCharsets.UTF_8)) {

Review Comment:
   `InputStreamReader(InputStream, Charset)` replaces malformed byte sequences 
with U+FFFD by default. Consequently, a Latin-1 byte such as `0xE9` inside a 
quoted field is silently indexed as a replacement character when the charset is 
absent or claims UTF-8; `assertUtf8()` only checks the declaration. Please use 
a UTF-8 decoder configured to report malformed input and translate decoding 
failures into a client error, rather than silently corrupting document values.



##########
solr/core/src/java/org/apache/solr/handler/loader/NDJsonParser.java:
##########
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.handler.loader;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.Arrays;
+import org.apache.solr.common.SolrException;
+import org.noggit.JSONParser;
+
+/**
+ * A {@link JSONParser} that enforces the newline delimited JSON contract 
while parsing: every
+ * document is a strict JSON object sitting on a line of its own. Newlines are 
only whitespace to
+ * JSON itself, so the contract is checked here rather than by framing the 
input into lines, which
+ * lets documents stream without ever being materialized as a line.
+ *
+ * <p>Tracking happens in {@link #fill()}, which sees every character exactly 
once as it enters the
+ * buffer, and in {@link #nextEvent()}, which observes where each top level 
object starts and ends.
+ */
+class NDJsonParser extends JSONParser {
+
+  private static final int BUFFER_SIZE = 8192;
+
+  /** Absolute offsets of newlines already seen by {@link #fill()}, not yet 
reached by parsing. */
+  private long[] newlines = new long[16];
+
+  private int head;
+  private int tail;
+
+  private boolean lastWasCarriageReturn;
+
+  /** Lines consumed up to {@link #consumedPosition}, 1-based. */
+  private long line = 1;
+
+  private long consumedPosition;
+
+  private long documentStartLine = -1;
+  private long previousDocumentEndLine = -1;
+
+  NDJsonParser(Reader in) {
+    super(in, new char[BUFFER_SIZE]);
+    // Every line has to be a JSON value, so none of noggit's lenient dialects 
are accepted
+    setFlags(FLAGS_STRICT);

Review Comment:
   `FLAGS_STRICT` still accepts invalid JSON here, including `-01`, `1.`, 
literal tab/NUL characters in strings, `\'` escapes, and non-breaking spaces 
between tokens. These inputs can reach indexing instead of returning the 
promised malformed-line error. Add NDJSON-specific number, string, and 
whitespace validation with regression tests, while preserving the existing JSON 
loader's permissive behavior.



##########
solr/core/src/java/org/apache/solr/handler/loader/NDJsonLoader.java:
##########
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.handler.loader;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.Set;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.util.ContentStream;
+import org.eclipse.jetty.http.MimeTypes;
+import org.noggit.JSONParser;
+import org.noggit.JSONParser.ParseException;
+
+/**
+ * Loads documents in <a href="https://jsonlines.org/";>Newline Delimited 
JSON</a> (ND-JSON, also
+ * known as JSON Lines or JSONL) format; one JSON object per line, each 
representing a document to
+ * add.
+ *
+ * <p>Documents are mapped exactly as on the {@code /update/json/docs} path: a 
nested JSON object is
+ * flattened into dotted field names unless the {@code split} parameter 
declares its path to be a
+ * nested document. Every line is an implicit add; the other update commands 
are not recognized, so
+ * use request parameters such as {@code commit} or a separate request for 
those.
+ *
+ * <p>The only differences from that path are the restrictions the format 
itself implies, all
+ * enforced while streaming: the content must be UTF-8, every document sits on 
a line of its own,
+ * and {@code split} must start at the document root, since a line is already 
one document.
+ */
+public class NDJsonLoader extends JsonLoader {
+
+  /** The content types that select this loader. */
+  public static final Set<String> CONTENT_TYPES =
+      Set.of(
+          "application/x-ndjson",
+          "application/jsonl",
+          "application/jsonlines",
+          "application/x-jsonlines");
+
+  @Override
+  public void load(
+      SolrQueryRequest req,
+      SolrQueryResponse rsp,
+      ContentStream stream,
+      UpdateRequestProcessor processor)
+      throws Exception {
+    assertUtf8(stream);
+    super.load(req, rsp, stream, processor);
+  }
+
+  @Override
+  protected SingleThreadedJsonLoader createLoader(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor 
processor) {
+    return new SingleThreadedNDJsonLoader(req, rsp, processor);
+  }
+
+  /**
+   * NDJSON is defined as UTF-8, so a stream declaring any other charset is 
rejected rather than
+   * silently decoded. A stream that declares no charset is read as UTF-8.
+   */
+  private static void assertUtf8(ContentStream stream) {
+    String charset = 
MimeTypes.getCharsetFromContentType(stream.getContentType());
+    if (charset != null && 
!StandardCharsets.UTF_8.equals(Charset.forName(charset, null))) {
+      throw new SolrException(
+          SolrException.ErrorCode.UNSUPPORTED_MEDIA_TYPE,
+          "NDJSON must be UTF-8 encoded, but charset=" + charset + " was 
declared");
+    }
+  }
+
+  private static class SingleThreadedNDJsonLoader extends 
SingleThreadedJsonLoader {
+
+    private NDJsonParser ndJsonParser;
+
+    SingleThreadedNDJsonLoader(
+        SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor 
processor) {
+      super(req, rsp, processor);
+    }
+
+    /** As the base loader, but reporting a malformed document by line rather 
than by offset. */
+    @Override
+    public void load(
+        SolrQueryRequest req,
+        SolrQueryResponse rsp,
+        ContentStream stream,
+        UpdateRequestProcessor processor)
+        throws Exception {
+      // The charset is already known to be UTF-8, so the stream is decoded as 
such
+      try (Reader reader = new InputStreamReader(stream.getStream(), 
StandardCharsets.UTF_8)) {
+        processUpdate(reader);
+      } catch (ParseException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.BAD_REQUEST,
+            "Cannot parse NDJSON at line " + ndJsonParser.getLineNumber() + ": 
" + e.getMessage());
+      }
+    }
+
+    /** NDJSON carries documents only, so this is always the document path, 
never commands. */
+    @Override
+    void processUpdate(Reader reader) throws IOException {
+      SolrParams params = req.getParams();
+      if (params.get("srcField") != null) {
+        throw new SolrException(
+            SolrException.ErrorCode.BAD_REQUEST,
+            "srcField is not supported for NDJSON; use the /update/json/docs 
path instead");
+      }
+      handleSplitMode(assertSplitStartsAtRoot(params.get("split")), 
params.getParams("f"), reader);
+    }
+
+    @Override
+    JSONParser createParser(Reader reader, String srcField) {
+      ndJsonParser = new NDJsonParser(reader);
+      return ndJsonParser;
+    }
+
+    /**
+     * Each line is already a document, so the first {@code split} path has to 
be the root. Further
+     * paths are what declares a nested document, and must sit below it.
+     */
+    private static String assertSplitStartsAtRoot(String split) {
+      if (split == null) {
+        return "/";
+      }
+      String[] paths = split.split("\\|");

Review Comment:
   For `split=%7C` (or any value consisting only of pipes), Java's default 
`split()` discards every empty component and returns an empty array. Accessing 
`paths[0]` then throws `ArrayIndexOutOfBoundsException`, returning HTTP 500 
instead of the intended HTTP 400 explaining the root requirement. Preserve 
empty components so this input reaches the existing validation branch.



##########
solr/core/src/java/org/apache/solr/handler/loader/NDJsonParser.java:
##########
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.handler.loader;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.Arrays;
+import org.apache.solr.common.SolrException;
+import org.noggit.JSONParser;
+
+/**
+ * A {@link JSONParser} that enforces the newline delimited JSON contract 
while parsing: every
+ * document is a strict JSON object sitting on a line of its own. Newlines are 
only whitespace to
+ * JSON itself, so the contract is checked here rather than by framing the 
input into lines, which
+ * lets documents stream without ever being materialized as a line.
+ *
+ * <p>Tracking happens in {@link #fill()}, which sees every character exactly 
once as it enters the
+ * buffer, and in {@link #nextEvent()}, which observes where each top level 
object starts and ends.
+ */
+class NDJsonParser extends JSONParser {
+
+  private static final int BUFFER_SIZE = 8192;
+
+  /** Absolute offsets of newlines already seen by {@link #fill()}, not yet 
reached by parsing. */
+  private long[] newlines = new long[16];
+
+  private int head;
+  private int tail;
+
+  private boolean lastWasCarriageReturn;
+
+  /** Lines consumed up to {@link #consumedPosition}, 1-based. */
+  private long line = 1;
+
+  private long consumedPosition;
+
+  private long documentStartLine = -1;
+  private long previousDocumentEndLine = -1;
+
+  NDJsonParser(Reader in) {
+    super(in, new char[BUFFER_SIZE]);
+    // Every line has to be a JSON value, so none of noggit's lenient dialects 
are accepted
+    setFlags(FLAGS_STRICT);
+  }
+
+  /** Noggit eats {@code #} comments as whitespace whatever the flags say; 
NDJSON has none. */
+  @Override
+  protected void getNewlineComment() {
+    throw rejectComment();
+  }
+
+  /** As {@link #getNewlineComment()}, for the slash spellings of a comment. */
+  @Override
+  protected void getSlashComment() {
+    throw rejectComment();
+  }
+
+  private SolrException rejectComment() {
+    return new SolrException(
+        SolrException.ErrorCode.BAD_REQUEST,
+        "Cannot parse NDJSON at line "
+            + lineNumberAtCurrentPosition()
+            + ": comments are not allowed");
+  }
+
+  /** The line the parser has reached, 1-based, for error reporting. */
+  long getLineNumber() {
+    return lineNumberAtCurrentPosition();
+  }
+
+  @Override
+  public int nextEvent() throws IOException {
+    int event = super.nextEvent();
+    int level = getLevel();
+    if (event == OBJECT_START && level == 1) {
+      long startLine = lineNumberAtCurrentPosition();
+      if (startLine == previousDocumentEndLine) {
+        throw new SolrException(
+            SolrException.ErrorCode.BAD_REQUEST,
+            "Cannot parse NDJSON at line "
+                + startLine
+                + ": expected a newline between documents, but found another 
document on the same"
+                + " line");
+      }
+      documentStartLine = startLine;
+    } else if (event == ARRAY_START && level == 1) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "Cannot parse NDJSON at line "
+              + lineNumberAtCurrentPosition()
+              + ": expected a JSON object, but found an array; NDJSON carries 
one object per line,"
+              + " with no enclosing array");
+    } else if (event == OBJECT_END && level == 0) {
+      assertOnDocumentLine();
+      previousDocumentEndLine = documentStartLine;
+      documentStartLine = -1;
+    } else if (level == 0 && event != EOF) {
+      // A stray value between documents would otherwise be skipped silently
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "Cannot parse NDJSON at line "
+              + lineNumberAtCurrentPosition()
+              + ": expected a JSON object, but found "
+              + getEventString(event));
+    } else if (level > 0 && documentStartLine > 0) {
+      assertOnDocumentLine();
+    }
+    return event;
+  }
+
+  /** A document must not span lines; this fires on the first token past an 
interior newline. */
+  private void assertOnDocumentLine() {
+    long current = lineNumberAtCurrentPosition();
+    if (current != documentStartLine) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "Cannot parse NDJSON at line "
+              + documentStartLine
+              + ": a document must be on a single line, but this one spans 
lines "
+              + documentStartLine
+              + " to "
+              + current);
+    }
+  }
+
+  @Override
+  protected void fill() throws IOException {
+    super.fill();

Review Comment:
   While `super.nextEvent()` skips a long run of blank lines, it repeatedly 
refills the buffer without retiring any queued newline offsets. Valid input 
therefore retains one long per newline and can exhaust the heap, rather than 
keeping memory bounded by the largest document. Consume the exhausted buffer's 
offsets before each refill.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to