package edu.caltech.nanodb.client; import java.io.BufferedReader; import java.io.FileNotFoundException; import java.io.FileReader; import java.io.IOException; import java.io.InputStreamReader; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.LogManager; import edu.caltech.nanodb.server.CommandResult; import org.antlr.v4.runtime.misc.ParseCancellationException; /** * This abstract class implements the basic functionality necessary for * providing an interactive SQL client. */ public abstract class InteractiveClient { private static Logger logger = LogManager.getLogger(InteractiveClient.class); /** A string constant specifying the "first-line" command-prompt. */ private static final String CMDPROMPT_FIRST = "CMD> "; /** A string constant specifying the "subsequent-lines" command-prompt. */ private static final String CMDPROMPT_NEXT = " > "; /** The buffer that accumulates each command's text. */ private StringBuilder enteredText; /** A flag that records if we are exiting the interactive client. */ private boolean exiting; /** * Start up the interactive client. The specific way the client * interacts with the server dictates how this startup mechanism * will work. * * @throws Exception if any error occurs during startup */ public abstract void startup() throws Exception; /** * This is the interactive mainloop that handles input from the standard * input stream of the program. */ protected void mainloop() { // We don't use the console directly, since we can't read/write it // if someone redirects a file onto the client's input-stream. boolean hasConsole = (System.console() != null); if (hasConsole) { System.out.println( "Welcome to NanoDB. Exit with EXIT or QUIT command.\n"); } exiting = false; BufferedReader bufReader = new BufferedReader(new InputStreamReader(System.in)); while (!exiting) { enteredText = new StringBuilder(); boolean firstLine = true; while (true) { try { if (hasConsole) { if (firstLine) { System.out.print(CMDPROMPT_FIRST); System.out.flush(); firstLine = false; } else { System.out.print(CMDPROMPT_NEXT); System.out.flush(); } } String line = bufReader.readLine(); if (line == null) { // Hit EOF. exiting = true; break; } enteredText.append(line).append('\n'); processEnteredText(); if (exiting) break; if (enteredText.length() == 0) firstLine = true; } catch (Throwable e) { System.out.println("Unexpected error: " + e.getClass() + ": " + e.getMessage()); logger.error("Unexpected error", e); } } } } /** * This helper function processes the contents of the {@link #enteredText} * field, consuming comments, handling client "shell commands" and * regular commands that are handled by the * {@link edu.caltech.nanodb.server.NanoDBServer}. Whatever command or * comment is processed will also be removed from the {@code enteredText} * buffer by this function. Note also that multiple commands will be * processed, if present. */ private void processEnteredText() { // Process any commands in the entered text. while (true) { // Consume leading whitespace while (enteredText.length() > 0 && Character.isWhitespace(enteredText.charAt(0))) { enteredText.deleteCharAt(0); } // Consume comments if (enteredText.length() >= 2) { if ("--".equals(enteredText.substring(0, 2))) { // Consume single-line comment int endIdx = 2; // Look for the end of the line. while (endIdx < enteredText.length() && enteredText.charAt(endIdx) != '\n') { endIdx++; } if (endIdx == enteredText.length()) { // Didn't find newline character. Can't consume this // comment yet. return; } endIdx++; // Skip the newline character as well. enteredText.delete(0, endIdx); // Go back and try to find more commands. continue; } else if ("/*".equals(enteredText.substring(0, 2))) { // Consume block comment int endIdx = 2; // Look for the end of the block comment. while (endIdx + 1 < enteredText.length() && (enteredText.charAt(endIdx) != '*' || enteredText.charAt(endIdx + 1) != '/')) { endIdx++; } if (endIdx + 1 == enteredText.length()) { // Didn't find end of block comment. Can't consume // this comment yet. return; } endIdx += 2; // Skip the end of the block-comment. enteredText.delete(0, endIdx); // Go back and try to find more commands. continue; } } // Look for shell commands if (enteredText.length() > 0 && enteredText.charAt(0) == '\\') { // This is a shell command, which continues to the // end of the current line. int endIdx = 0; while (endIdx < enteredText.length() && enteredText.charAt(endIdx) != '\n') { endIdx++; } endIdx++; // Include the newline character too String shellCommand = enteredText.substring(0, endIdx); enteredText.delete(0, endIdx); handleShellCommand(shellCommand); continue; } String command = getCommandString(); if (command == null) break; // Couldn't find a complete command. // if (logger.isDebugEnabled()) // logger.debug("Command string:\n" + command); CommandResult result = handleCommand(command); if (result.isExit()) { exiting = true; break; } else { outputCommandResult(result); } } } /** * This helper method goes through the {@link #enteredText} buffer, trying * to identify the extent of the next command string. This is done using * semicolons (that are not enclosed with single or double quotes). If a * command is identified, it is removed from the internal buffer and * returned. If no complete command is identified, {@code null} is * returned. * * @return the first semicolon-terminated command in the internal data * buffer, or {@code null} if the buffer contains no complete * commands. */ private String getCommandString() { int i = 0; String command = null; while (i < enteredText.length()) { char ch = enteredText.charAt(i); if (ch == ';') { // Found the end of the command. Extract the string, and // make sure the semicolon is also included. command = enteredText.substring(0, i + 1); enteredText.delete(0, i + 1); // Consume any leading whitespace at the start of the entered // text. while (enteredText.length() > 0 && Character.isWhitespace(enteredText.charAt(0))) { enteredText.deleteCharAt(0); } break; } else if (ch == '\'' || ch == '"') { // Need to ignore all subsequent characters until we find // the end of this quoted string. i++; while (i < enteredText.length() && enteredText.charAt(i) != ch) { i++; } } i++; // Go on to the next character. } return command; } /** * Subclasses can implement this method to handle each command entered * by the user. For example, a subclass may send the command over a * socket to the server, wait for a response, then output the response * to the console. * * @param command the command to handle. * * @return the command-result from executing the command */ public abstract CommandResult handleCommand(String command); /** * Handle "shell commands," which are commands that the client itself * handles on behalf of the user. Shell commands start with a backslash * "\" character. * * @param shellCommand the command to handle. */ private void handleShellCommand(String shellCommand) { // Split the shell command into parts String[] parts = shellCommand.split("\\s+", 2); parts[0] = parts[0].toLowerCase(); if ("\\source".equals(parts[0])) { // Source the requested SQL file. StringBuilder oldText = enteredText; enteredText = new StringBuilder(); String filename = parts[1].strip(); // Open the file with a try-with-resources so it will always be // closed when we are done with the file. try (BufferedReader reader = new BufferedReader(new FileReader(filename))) { while (true) { String line = reader.readLine(); if (line == null) break; // Inject the line of the file into the buffer. enteredText.append(line).append('\n'); // Attempt to process any operations in the entered text. // If there are no complete commands, this will be a // no-op. If there are multiple complete commands, they // will all be processed. processEnteredText(); } } catch (FileNotFoundException e) { System.out.println("ERROR: Could not open file \"" + filename + "\""); } catch (IOException e) { System.out.println("ERROR: Could not read file \"" + filename + "\": " + e.getMessage()); } enteredText = oldText; } else if ("\\help".equals(parts[0])) { // Show help information. System.out.println("You can enter any NanoDB SQL command, or " + "the following built-in commands."); System.out.println("EXIT; or QUIT; will exit the NanoDB client."); System.out.println(); System.out.println("\\help"); System.out.println("\tDisplays this help information."); System.out.println(); System.out.println("\\source filename.sql"); System.out.println("\tLoads and executes the contents of \"filename.sql\"."); System.out.println(); } else { System.out.println("ERROR: Unrecognized shell command \"" + parts[0] + "\""); } } /** * Outputs relevant information from the command-result object. * * @param result the command-result object to output */ private void outputCommandResult(CommandResult result) { // TODO: Right now we only print out error information. In the // future we will want to output other details. This // functionality also needs to be integrated with the tuple- // output code from the server. Need to think about this more. if (result.failed()) { Exception e = result.getFailure(); if (e instanceof ParseCancellationException) { System.out.println("ERROR: Could not parse command"); } else { System.out.println("ERROR: " + e.getMessage()); } } } /** * Shut down the interactive client. The specific way the client * interacts with the server dictates how this shutdown mechanism * will work. * * @throws Exception if any error occurs during shutdown */ public abstract void shutdown() throws Exception; }