diff --git a/.github/workflows/sql-cli-test-and-build-workflow.yml b/.github/workflows/sql-cli-test-and-build-workflow.yml index e095d96..9433a68 100644 --- a/.github/workflows/sql-cli-test-and-build-workflow.yml +++ b/.github/workflows/sql-cli-test-and-build-workflow.yml @@ -18,7 +18,7 @@ jobs: working-directory: . strategy: matrix: - python-version: [3.8] + python-version: [3.12] opensearch-version: [ latest ] steps: @@ -60,7 +60,7 @@ jobs: cp -r ./dist/*.tar.gz ./dist/*.whl opensearchsql-builds/ - name: Upload Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: opensearchsql path: opensearchsql-builds diff --git a/.gitignore b/.gitignore index 34fb496..9fe5a5c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ var/ *.egg-info/ .installed.cfg *.egg +remote/ # PyInstaller # Usually these files are written by a python script from a template @@ -67,6 +68,15 @@ target/ *.deb *.rpm +# Gradle +.gradle + +# Commands history +.cli_history + +# Saved Query +**/saved.txt + .vscode/ venv/ diff --git a/.metals/metals.lock.db b/.metals/metals.lock.db new file mode 100644 index 0000000..7938d15 --- /dev/null +++ b/.metals/metals.lock.db @@ -0,0 +1,6 @@ +#FileLock +#Tue Aug 05 12:12:27 PDT 2025 +hostName=localhost +id=1987b9c15e1a4df375ba3d8b830423f0a7b05ab4b23 +method=file +server=localhost\:59810 diff --git a/README.md b/README.md index 1f9f734..273f58b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,19 @@ + + +- [OpenSearch SQL CLI](#OpenSearch-SQL-CLI) +- [Query Compatibility Testing](#query-compatibility-testing) +- [SQL CLI](#sql-cli) +- [Features](#features) +- [Version](#version) +- [Install](#install) +- [Startup Commands](#startup-commands) +- [Interactive Mode Commands](#interactive-mode-commands) +- [Configuration](#configuration) +- [Using the CLI](#using-the-cli) +- [Code of Conduct](#code-of-conduct) +- [Security Issue Notifications](#security-issue-notifications) +- [Licensing](#licensing) +- [Copyright](#copyright) [![SQL CLI Test and Build](https://github.com/opensearch-project/sql-cli/workflows/SQL%20CLI%20Test%20and%20Build/badge.svg)](https://github.com/opensearch-project/sql-cli/actions) [![Latest Version](https://img.shields.io/pypi/v/opensearchsql.svg)](https://pypi.python.org/pypi/opensearchsql/) @@ -8,14 +24,28 @@ # OpenSearch SQL CLI +Interactive command-line interface (CLI) for executing PPL (Piped Processing Language) and SQL queries against OpenSearch clusters. Supports secure and insecure endpoints, AWS SigV4 authentication, autocomplete, syntax highlighting, configurable output formats (Table, JSON, CSV), and saved query history. Easily toggle language modes, SQL plugin versions, and vertical display formatting - all from a single terminal session. + The SQL CLI component in OpenSearch is a stand-alone Python application and can be launched by a 'wake' word `opensearchsql`. -It only supports [OpenSearch SQL Plugin](https://opensearch.org/docs/latest/search-plugins/sql/) -You must have the OpenSearch SQL plugin installed to your OpenSearch instance to connect. Users can run this CLI from Unix like OS or Windows, and connect to any valid OpenSearch end-point such as Amazon OpenSearch Service. ![](screenshots/usage.gif) +### Query Compatibility Testing + +Users can test their existing queries against newer OpenSearch SQL plug-in versions before upgrading their OpenSearch clusters. + +For example, user is currently using version **2.19** may want to validate query compatibility with version **3.1** first. + +By using this CLI tool, they can: +- Load and run SQL 3.1 logic locally, without upgrading their OpenSearch cluster. +- Verify that their current queries execute as expected under the new SQL engine. +- Avoid potential breaking changes and reduce the need for rollback in production. + +Moreover, developers can use this to test their own SQL plug-in implementation. + +This CLI acts as a safe testing environment, allowing smooth transitions between versions with confidence. ### SQL CLI @@ -26,19 +56,44 @@ Users can run this CLI from Unix like OS or Windows, and connect to any valid Op [sql-cli-build-badge]: https://github.com/opensearch-project/sql-cli/actions/workflows/sql-cli-test-and-build-workflow.yml/badge.svg [sql-cli-build-link]: https://github.com/opensearch-project/sql-cli/actions/workflows/sql-cli-test-and-build-workflow.yml +## Requirements + +- **Python** version 3.12+ +- **Java** version 21 + ## Features -* Multi-line input -* Autocomplete for SQL syntax and index names -* Syntax highlighting -* Formatted output: -* Tabular format -* Field names with color -* Enabled horizontal display (by default) and vertical display when output is too wide for your terminal, for better visualization -* Pagination for large output -* Connect to OpenSearch with/without security enabled on either **OpenSearch or Amazon OpenSearch Service domains**. -* Supports loading configuration files -* Supports all SQL plugin queries +- **Multi-line input** +- **Autocomplete** for SQL, PPL, index names +- **Syntax highlighting** +- **Formatted output** + - Table + - JSON + - CSV +- **Field names** displayed with color +- **Horizontal display** for table format + - Vertical display automatically used when output is too wide + - Toggle vertical mode on/off with `-v` +- **Connect to OpenSearch** + - Works with or without OpenSearch security enabled + - Supports Amazon OpenSearch Service domains +- **Query operations** + - Execute queries + - Explain plans + - Save and load queries +- **SQL plugin version selection** + - Maven respository + - Local directory + - Git clone +- **Command history** + - `src/main/python/opensearchsql_cli/.cli_history` +- **Configuration file** + - `src/main/python/opensearchsql_cli/config/config_file.yaml` +- **SQL plug-in connection log** + - `src/main/java/sql_library.log` +- **Gradle log** + - `sqlcli_build.log`: SQL CLI jar + - `sql_build.log`: SQL Plug-in jar ## Version Unlike plugins which use 4-digit version number. SQl-CLI uses `x.x.x` as version number same as other python packages in OpenSearch family. As a client for OpenSearch SQL, it has independent release. @@ -58,86 +113,207 @@ To install the SQL CLI: 1. We suggest you install and activate a python3 virtual environment to avoid changing your local environment: - ``` - pip install virtualenv - virtualenv venv - cd venv - source ./bin/activate - ``` +``` + pip install virtualenv + virtualenv venv + cd venv + source ./bin/activate +``` -1. Install the CLI: +2. Install the CLI: - ``` - pip3 install opensearchsql - ``` +> TODO: Right now, user can install `pip install -e .` at the root directory until the current version package being published. - The SQL CLI only works with Python 3, since Python 2 is no longer maintained since 01/01/2020. See https://pythonclock.org/ + ``` + pip install opensearchsql + ``` + The SQL CLI only works with Python 3, since Python 2 is no longer maintained since 01/01/2020. See https://pythonclock.org/ -1. To launch the CLI, run: - ``` - opensearchsql https://localhost:9200 --username admin --password < Admin password > - ``` - By default, the `opensearchsql` command connects to [http://localhost:9200](http://localhost:9200/). +3. To launch the CLI, run: + + ``` + opensearchsql + ``` + +## Startup Commands + +### Defaults: if no arguments provided +- **Language**: PPL +- **Endpoint**: `http://localhost:9200` +- **Output Format**: Table +- **SQL Plugin Version**: Latest version + +### If not specify protocol or port number + - The default protocol is **HTTP** with port number **9200**. + - If using **HTTPS** without specifying a port, port **443** is used by default. + +| Options | Description | +|---------------------------------------|-------------------------------------------------------------------------------| +| `-e`, `--endpoint` `` | Set the OpenSearch endpoint (e.g., `protocol://domain:port`) | +| `-u`, `--user` `` | Provide credentials for secure clusters | +| `-k`, `--insecure` | Ignore SSL certificate verification (use with `https` protocol) | +| `-l`, `--language` `` | Choose query language: `ppl` or `sql` | +| `-f`, `--format` `` | Set output format: `table`, `json`, or `csv` | +| `-q`, `--query` `` | Single query execution | +| `--version` `` | Set OpenSearch SQL plugin version (e.g., `3.1`, `2.19`) | +| `--local` `` | Use a local directory containing the SQL plugin JAR | +| `--remote` `` | Clone from a git repository URL | +| `-b`, `--branch` `` | Branch name to clone (default is main) | +| `-o`, `--output` `` | Custom output directory for cloned repository (used with `--remote`) | +| `--rebuild` | Rebuild or update the corresponding JAR file | +| `-c`, `--config` | Show current configuration values | +| `--help` | Show help message and usage examples | + +### Example Usages + +```bash +# Start with all defaults +opensearchsql +# Use secure endpoint with credentials +opensearchsql -e https://localhost:9200 -u admin:password -k +# Use AWS SigV4 connection +opensearchsql --aws-auth amazon.com -## Configure +# Use SQL and JSON output +opensearchsql -l sql -f json + +# Single query execution +opensearchsql -q "source=index_name" + +# Load specific plugin version +opensearchsql --version 2.19 + +# Use a local SQL plugin directory +opensearchsql --local /path/to/sql/plugin/directory + +# Use a remote git repository with main branch +opensearchsql --remote "https://github.com/opensearch-project/sql.git" + +# Use a remote git repository with a specific branch +opensearchsql --remote "https://github.com/opensearch-project/sql.git" -b "feature-branch" + +# Clone a repository to a custom directory +opensearchsql --remote "https://github.com/opensearch-project/sql.git" -o /path/to/custom/directory +``` -When you first launch the SQL CLI, a configuration file is automatically created at `~/.config/opensearchsql-cli/config` (for MacOS and Linux), the configuration is auto-loaded thereafter. +## Interactive Mode Commands + +| Options | Description | +|----------------------------------|-------------------------------------------------------| +| `` | Execute a query | +| `-l ` | Change language: `ppl`, `sql` | +| `-f ` | Change output format: `table`, `json`, or `csv` | +| `-v` | Toggle vertical table display mode | +| `-s --save ` | Save the latest query with a given name | +| `-s --load ` | Load and execute a saved query | +| `-s --remove ` | Remove a saved query by name | +| `-s --list` | List all saved query names | +| `help` | Show this help message | +| `exit`, `quit`, `q` | Exit the interactive mode | + +### Version Switching +To use a different OpenSearch SQL plug-in version, you must restart the CLI + +## Configuration + +When you first launch the SQL CLI, a configuration file is automatically loaded. You can also configure the following connection properties: +### Main -* `endpoint`: You do not need to specify an option, anything that follows the launch command `opensearchsql` is considered as the endpoint. If you do not provide an endpoint, by default, the SQL CLI connects to [http://localhost:9200](http://localhost:9200/). -* `-u/-w`: Supports username and password for HTTP basic authentication, such as: - * OpenSearch with [OpenSearch Security Plugin](https://opensearch.org/docs/latest/security/) installed - * Amazon OpenSearch Service domain with [Fine Grained Access Control](https://docs.aws.amazon.com/opensearch-service/latest/developerguide/fgac.html) enabled -* `--aws-auth`: Turns on AWS sigV4 authentication to connect to an Amazon Elasticsearch Service endpoint. Use with the AWS CLI (`aws configure`) to retrieve the local AWS configuration to authenticate and connect. +| Key | Description | Options | Default | +|--------------|--------------------------------------------------------|-----------------|-----------| +| `multi_line` | allows breaking up the statements into multiple lines | `true`, `false` | `false` | -For a list of all available configurations, see [clirc](src/opensearch_sql_cli/conf/clirc). +### Connection Settings +| Key | Description | Example | Default | +|------------|---------------------------------------------------------------|---------------------|-----------------| +| `endpoint` | OpenSearch URL (`http://localhost:9200`, `https://localhost:9200`, or AWS SigV4 endpoint) | `localhost:9200` | `localhost:9200`| +| `username` | Username for HTTPS authentication *(use `""` if not set)* | `"admin"` | `""` | +| `password` | Password for HTTPS authentication *(use `""` if not set)* | `"admin"` | `""` | +| `insecure` | Skip certificate validation (`-k` flag) | `true` / `false` | `false` | +| `aws_auth` | Use AWS SigV4 authentication | `true` / `false` | `false` | +> ⚠️ **Security Warning**: Passwords stored in this file are not encrypted. Consider using `-u username:password` instead for sensitive environments. -## Using the CLI +### Query Settings -1. Save the sample [accounts test data](https://github.com/opensearch-project/sql/blob/main/integ-test/src/test/resources/accounts.json) file. -2. Index the sample data. +| Key | Description | Options | Default | +|------------|----------------------------------------|----------------------------|----------| +| `language` | Query language | `ppl`, `sql` | `ppl` | +| `format` | Output format | `table`, `json`, `csv` | `table` | +| `vertical` | Use vertical table display mode | `true` / `false` | `false` | - ``` - curl -H "Content-Type: application/x-ndjson" -POST https://localhost:9200/data/_bulk -u admin:< Admin password > --insecure --data-binary "@accounts.json" - ``` +### SQL Version Settings +| Key | Description | Example | Default | +|----------------|----------------------------------------------|---------------------------------------------------|----------| +| `version` | Use Maven repository version (as a string) | `"3.1"` | `""` | +| `local` | Use local JAR files with absolute path | `"/path/to/sql/plugin/directory"` | `""` | +| `remote` | Git repository URL to clone | `"https://github.com/opensearch-project/sql.git"` | `""` | +| `branch_name` | Branch name to clone from the repository | `"feature-branch"` | `""` | +| `remote_output`| Custom directory for cloned repository | `"/path/to/custom/directory"` | `""` | -1. Run a simple SQL command in OpenSearch SQL CLI: +### SQL Plugin Settings - ``` - SELECT * FROM accounts; - ``` +| Key | Description | Default | +|-------------------------------------------------|-----------------------------------------------|----------| +| `QUERY_SIZE_LIMIT` | Maximum number of rows returned per query | `200` | +| `FIELD_TYPE_TOLERANCE` | Tolerate field type mismatches | `true` | +| `CALCITE_ENGINE_ENABLED` | Enable the Calcite SQL engine | `true` | +| `CALCITE_FALLBACK_ALLOWED` | Fallback to legacy engine if Calcite fails | `true` | +| `CALCITE_PUSHDOWN_ENABLED` | Enable pushdown optimization in Calcite | `true` | +| `CALCITE_PUSHDOWN_ROWCOUNT_ESTIMATION_FACTOR` | Row count estimation factor for pushdown | `1.0` | +| `SQL_CURSOR_KEEP_ALIVE` | Cursor keep-alive time in minutes | `1` | - By default, you see a maximum output of 200 rows. To show more results, add a `LIMIT` clause with the desired value. +> **Note**: **PPL Calcite** result is limited by `QUERY_SIZE_LIMIT` number -The CLI supports all types of query that OpenSearch SQL supports. Refer to [OpenSearch SQL basic usage documentation.](https://github.com/opensearch-project/sql/blob/main/docs/user/dql/basics.rst) +### File Paths +| Key | Description | Default Path | +|-----------------|-----------------------|----------------------------------------------------------------| +| `sql_log` | SQL library log | `src/main/java/sql_library.log` | +| `history_file` | CLI command history | `src/main/python/opensearchsql_cli/.cli_history` | +| `saved_query` | Saved query | `src/main/python/opensearchsql_cli/query/save_query/saved.txt` | -## Query options +### Custom Colors -Run single query from command line with options +The CLI supports customizing the colors of various UI elements through the config file. You can modify these settings to match your terminal theme or personal preferences. +Color format: `"bg: [style]"` where colors are hex values and style can be `bold`, `italic`, etc. -* `--help`: help page for options -* `-q`: follow by a single query -* `-f`: support *jdbc/raw* format output -* `-v`: display data vertically -* `-e`: translate sql to DSL +For a list of all available configurations, see [config.yaml](src/main/python/opensearchsql_cli/config/config.yaml). -## CLI Options -* `-l`: Query language option. Available options are [sql, ppl]. By default it's using sql. -* `-p`: always use pager to display output -* `--clirc`: provide path of config file to load + +## Using the CLI + +1. Save the sample [accounts test data](https://github.com/opensearch-project/sql/blob/main/integ-test/src/test/resources/accounts.json) file. +2. Index the sample data. + + ``` + curl -H "Content-Type: application/x-ndjson" -POST https://localhost:9200/data/_bulk -u admin:< Admin password > --insecure --data-binary "@accounts.json" + ``` + + +1. Run a simple SQL/PPL command in OpenSearch SQL CLI: + + ```sql + # PPL + source=accounts + # SQL + SELECT * FROM accounts + ``` + +The CLI supports all types of query that OpenSearch PPL/SQL supports. Refer to [OpenSearch SQL basic usage documentation.](https://github.com/opensearch-project/sql/blob/main/docs/user/dql/basics.rst) + ## Code of Conduct @@ -155,5 +331,3 @@ See the [LICENSE](LICENSE.TXT) file for our project's licensing. We will ask you ## Copyright Copyright OpenSearch Contributors. See [NOTICE](NOTICE) for details. - - diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..8643da8 --- /dev/null +++ b/build.gradle @@ -0,0 +1,533 @@ +plugins { + id 'java' + id 'application' + id 'base' + id 'com.github.johnrengelman.shadow' version '8.1.1' + id 'com.diffplug.spotless' version '7.1.0' +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +application { + mainClass = 'Gateway' +} + +repositories { + mavenCentral() + maven { + url "https://aws.oss.sonatype.org/content/repositories/snapshots/" + } + maven { + url "https://jitpack.io" + } +} + +// Helper function for version detection +ext.getEffectiveVersion = { -> + // Look for task names with version format (e.g., 3_1_0_0 or 3_x) + def taskName = gradle.startParameter.taskNames.find { it.count("_") >= 1 && it.matches("[0-9].*") } + if (taskName != null) { + // Check if it's a local version task (ends with _local) + if (taskName.endsWith("_local")) { + return taskName.substring(0, taskName.lastIndexOf("_local")).replace('_', '.') + } + return taskName.replace('_', '.') + } + // Use default version when no version is specified + return project.ext.defaultVersion +} + +// Helper function to check if a task is for local JAR +ext.isLocalJarTask = { String taskName -> + return taskName.endsWith("_local") +} + +// Helper function to get local JAR directory +ext.getLocalJarDir = { -> + return project.hasProperty('localJarDir') ? project.getProperty('localJarDir') : null +} + +ext.updateIsV3orAbove = { String version -> + project.ext.isV3orAbove = version?.startsWith("3") ?: false + return project.ext.isV3orAbove +} + +ext.getOpenSearchClientVersion = { String version -> + def parts = version.split("\\.") + // 2.19.1 -> 2.19.0 format for OpenSearch client dependencies + if (parts.length >= 3) { + return "${parts[0]}.${parts[1]}.0" + } + return version +} + +// Helper function to determine which HTTP client to use based on version +ext.getHttpClientInfo = { -> + def result = [:] + + if (project.ext.isV3orAbove) { + result.clientName = "HTTP5" + result.sourceSet = sourceSets.http5 + result.dependencies = configurations.http5Dependency + } else { + result.clientName = "HTTP4" + result.sourceSet = sourceSets.http4 + result.dependencies = configurations.http4Dependency + } + + return result +} + + +ext { + // Default version to use when no specific version is specified + defaultVersion = "3.1.0.0" + + submodules = ['common', 'core', 'opensearch', 'ppl', 'sql', 'protocol', 'datasources'] + + // TODO: will need to see why using datasources on Maven fails + // but using local jar file works + mavenSubmodules = ['common', 'core', 'opensearch', 'ppl', 'sql', 'protocol'] + + // Global variable to determine if version is 3 or above + // Initialize with default value, will be updated when needed + isV3orAbove = false + + // Update isV3orAbove based on current version + updateIsV3orAbove(getEffectiveVersion()) + + // Common dependencies shared across all versions + sharedDeps = [ + // OpenSearch + 'org.apache.calcite:calcite-core:1.40.0', + 'com.facebook.presto:presto-matching:0.293', + 'org.antlr:antlr4-runtime:4.7.1', + 'org.apache.commons:commons-lang3:3.17.0', + 'org.reactivestreams:reactive-streams:1.0.4', + 'com.google.guava:guava:32.4.8-jre', + // AWS SDK v2 + 'software.amazon.awssdk:sdk-core:2.31.63', + 'software.amazon.awssdk:auth:2.31.63', + 'software.amazon.awssdk:regions:2.31.63', + 'software.amazon.awssdk:apache-client:2.31.63', + 'software.amazon.awssdk:sts:2.31.63', + 'software.amazon.awssdk:aws-core:2.31.63', + // Logging + 'org.apache.logging.log4j:log4j-core:2.25.0', + 'org.apache.logging.log4j:log4j-api:2.25.0', + // Logback + 'ch.qos.logback:logback-classic:1.5.18', + // Apache Commons Configuration for YAML file parsing + 'org.apache.commons:commons-configuration2:2.12.0', + 'commons-beanutils:commons-beanutils:1.11.0', + 'org.yaml:snakeyaml:2.2', + // JSON + 'org.json:json:20250517', + 'com.google.code.gson:gson:2.13.1', + // Guice, dependency injection + 'com.google.inject:guice:7.0.0', + // Py4J + 'net.sf.py4j:py4j:0.10.9.9' + ] + + // HTTP5 dependencies for v3 and above + http5Deps = [ + 'org.apache.httpcomponents.core5:httpcore5:5.2', + 'org.apache.httpcomponents.client5:httpclient5:5.2.1' + ] + + // HTTP4 dependencies for below v3 + http4Deps = [ + 'org.apache.httpcomponents:httpcore:4.4.16', + 'org.apache.httpcomponents:httpclient:4.5.14', + 'io.github.acm19:aws-request-signing-apache-interceptor:3.0.0' + ] + + // OpenSearch client dependencies + opensearchClientDeps = [ + 'opensearch-rest-high-level-client', + 'opensearch-rest-client', + 'opensearch-java' + ] + + metaInfExclusions = [ + 'META-INF/*.SF', + 'META-INF/*.DSA', + 'META-INF/*.RSA', + 'META-INF/*.EC', + 'META-INF/MANIFEST.MF' + ] + + jvmArgs = [ + '--add-opens=java.base/sun.nio.ch=ALL-UNNAMED', + '--add-opens=java.base/java.io=ALL-UNNAMED', + '--add-opens=java.base/sun.misc=ALL-UNNAMED' + ] +} + +configurations { + sharedDependency + http4Dependency + http5Dependency + + implementation { + extendsFrom sharedDependency + } +} + +// Define source sets +sourceSets { + main { + java { + srcDirs = ['src/main/java'] + // Base source set excludes both HTTP client implementations + exclude 'client/http4/**', 'client/http5/**' + } + } + + http4 { + java { + srcDirs = ['src/main/java'] + // Include only common files and HTTP4 files + include 'client/http4/**' + include '**/*.java' + exclude 'client/http5/**' + } + } + + http5 { + java { + srcDirs = ['src/main/java'] + // Include only common files and HTTP5 files + include 'client/http5/**' + include '**/*.java' + exclude 'client/http4/**' + } + } +} + +dependencies { + sharedDeps.each { d -> + sharedDependency d + } + + http4Deps.each { d -> + http4Dependency d + } + + http5Deps.each { d -> + http5Dependency d + } +} + +jar { + archiveBaseName.set("opensearchsql") +} + +// Configure compile tasks for all source sets +tasks.withType(JavaCompile).configureEach { + options.compilerArgs += project.ext.jvmArgs +} + +// Configure HTTP4 and HTTP5 compile tasks +tasks.named('compileHttp4Java') { + classpath = configurations.http4Dependency + configurations.sharedDependency + sourceSets.main.compileClasspath + // Skip this task when using v3 or above + onlyIf { + task -> + def result = !project.ext.isV3orAbove + if (result) { + println "Using HTTP4 client for version ${project.ext.getEffectiveVersion()}" + } + return result + } +} + +tasks.named('compileHttp5Java') { + classpath = configurations.http5Dependency + configurations.sharedDependency + sourceSets.main.compileClasspath + // Skip this task when using below v3 + onlyIf { + task -> + def result = project.ext.isV3orAbove + if (result) { + println "Using HTTP5 client for version ${project.ext.getEffectiveVersion()}" + } + return result + } +} + +// Configure the classes task to depend on the appropriate compile task +tasks.named('classes').configure { + dependsOn { + project.ext.isV3orAbove ? ['compileHttp5Java'] : ['compileHttp4Java'] + } +} + +applicationDefaultJvmArgs = project.ext.jvmArgs + +spotless { + java { + target fileTree('.') { + include '**/*.java' + exclude '**/build/**', '**/build-*/**', 'src/main/gen/**' + } + importOrder() + licenseHeader("/*\n" + + " * Copyright OpenSearch Contributors\n" + + " * SPDX-License-Identifier: Apache-2.0\n" + + " */\n\n") + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + googleJavaFormat('1.17.0').reflowLongStrings().groupArtifact('com.google.googlejavaformat:google-java-format') + } +} + + +def createShadowJarTask(String taskName, String versionLabel, Configuration config) { + tasks.register(taskName, com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + archiveBaseName.set("opensearchsqlcli-${versionLabel}") + configurations = [config] + + // Include the main source set output + from(sourceSets.main.output) + + // Include the appropriate HTTP client source set based on version + def httpClientInfo = project.ext.getHttpClientInfo() + from(httpClientInfo.sourceSet.output) + + // Configure shadow jar settings + manifest { + attributes 'Main-Class': 'Gateway' + } + + // Exclude signature files to avoid conflicts + project.ext.metaInfExclusions.each { pattern -> + exclude pattern + } + + mergeServiceFiles() + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + zip64 = true + } +} + +// Create a shadow jar task for local JAR files +def createLocalShadowJarTask(String taskName, String versionLabel, String localJarDir) { + def localConfigName = "${versionLabel.replace('.', '_')}_local" + def localDependencyConfigName = "${localConfigName}_Dependency" + + tasks.register(taskName, com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + archiveBaseName.set("opensearchsqlcli-${versionLabel}") + + // Include all dependencies + configurations = [ + configurations.sharedDependency, + configurations[localConfigName], + configurations[localDependencyConfigName] + ] + + doFirst { + println "Including dependencies from sharedDependency:" + configurations.sharedDependency.each { file -> + println " - ${file.name}" + } + } + + // Include the main source set output + from(sourceSets.main.output) + + // Include the appropriate HTTP client source set based on version + def httpClientInfo = project.ext.getHttpClientInfo() + from(httpClientInfo.sourceSet.output) + + // Include HTTP client dependencies + configurations += [httpClientInfo.dependencies] + + // Configure shadow jar settings + manifest { + attributes 'Main-Class': 'Gateway' + } + + // Exclude signature files to avoid conflicts + project.ext.metaInfExclusions.each { pattern -> + exclude pattern + } + + mergeServiceFiles() + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + zip64 = true + } +} + +// Helper function to configure runtime configuration based on version +ext.configureRuntimeConfiguration = { String version, String configName, String dependencyConfigName -> + def httpClientInfo = project.ext.getHttpClientInfo() + + configurations[dependencyConfigName].extendsFrom( + configurations.sharedDependency, + httpClientInfo.dependencies, + configurations[configName] + ) + + println "Version ${version}: Using ${httpClientInfo.clientName} dependencies and source files" +} + +ext.createVersionConfigurations = { String version -> + def configName = "${version.replace('.', '_')}" + def dependencyConfigName = "${configName}_Dependency" + + if (!configurations.findByName(configName)) { + configurations.create(configName) + configurations.create(dependencyConfigName) + + // Update the global isV3orAbove variable + project.ext.updateIsV3orAbove(version) + + // Configure runtime configuration based on version + configureRuntimeConfiguration(version, configName, dependencyConfigName) + + // Add unified-query dependencies + project.ext.mavenSubmodules.each { name -> + dependencies.add(configName, "org.opensearch.query:unified-query-${name}:${version}-SNAPSHOT") + } + + // Add datasources JAR file + dependencies.add(configName, files('submodule/datasources-3.1.0.0-SNAPSHOT.jar')) + + // Add OpenSearch client dependencies with appropriate version + def clientVersion = project.ext.getOpenSearchClientVersion(version) + + project.ext.opensearchClientDeps.each { dep -> + dependencies.add(configName, "org.opensearch.client:${dep}:${clientVersion}") + } + + println "Version ${version}: Using OpenSearch client version ${clientVersion}" + + // Create shadow jar task for this version + createShadowJarTask(configName, version, configurations[dependencyConfigName]) + + println "Version ${version}: Created configuration and task name as ${configName}" + } + + return configName +} + +// Create configurations for local JAR files +ext.createLocalConfigurations = { String version, String localJarDir -> + def configName = "${version.replace('.', '_')}_local" + def dependencyConfigName = "${configName}_Dependency" + + if (!configurations.findByName(configName)) { + configurations.create(configName) + configurations.create(dependencyConfigName) + + // Update the global isV3orAbove variable + project.ext.updateIsV3orAbove(version) + + // Configure runtime configuration based on version + configureRuntimeConfiguration(version, configName, dependencyConfigName) + + // Add each submodule JAR file to the configuration + project.ext.submodules.each { submodule -> + def submoduleJarPath = "${localJarDir}/${submodule}/build/libs/${submodule}-${version}-SNAPSHOT.jar" + def submoduleJarFile = new File(submoduleJarPath) + if (submoduleJarFile.exists()) { + println "Adding submodule JAR file to configuration: ${submoduleJarPath}" + dependencies.add(configName, files(submoduleJarFile)) + } else { + println "Warning: Submodule JAR file not found: ${submoduleJarPath}" + } + } + + // Add OpenSearch client dependencies with appropriate version + def clientVersion = project.ext.getOpenSearchClientVersion(version) + + project.ext.opensearchClientDeps.each { dep -> + dependencies.add(configName, "org.opensearch.client:${dep}:${clientVersion}") + } + + // Add OpenSearch core dependency + dependencies.add(configName, "org.opensearch:opensearch:${clientVersion}") + + println "Version ${version}: Using OpenSearch client version ${clientVersion}" + + // Create shadow jar task for this version + createShadowJarTask(configName, version, configurations[dependencyConfigName]) + + println "Version ${version}: Created configuration and task name as ${configName}" + } + + return configName +} + +// Use Gradle's task rules for dynamic task creation +tasks.addRule("Pattern: : Creates a task for the specified version") { String taskName -> + if (taskName.count("_") >= 1 && taskName.matches("[0-9].*")) { + def version = taskName.replace('_', '.') + + // Check if it's a local JAR task + if (project.ext.isLocalJarTask(taskName)) { + // Extract the version without the _local suffix + version = taskName.substring(0, taskName.lastIndexOf("_local")).replace('_', '.') + + // Get the local JAR directory + def localJarDir = project.ext.getLocalJarDir() + + if (localJarDir == null) { + throw new GradleException("Local JAR directory not specified. Use -PlocalJarDir=/path/to/local/jar") + } + + // Create configurations for local JAR files + def localConfigName = project.ext.createLocalConfigurations(version, localJarDir) + + // Configure implementation directly + def httpClientInfo = project.ext.getHttpClientInfo() + configurations.implementation.extendsFrom = [ + configurations.sharedDependency, + httpClientInfo.dependencies, + configurations[localConfigName] + ] as Set + + println "Version ${version}: Set implementation configuration for local JAR using directory ${localJarDir}" + } else { + def configName = project.ext.createVersionConfigurations(version) + + // Configure implementation directly + def httpClientInfo = project.ext.getHttpClientInfo() + configurations.implementation.extendsFrom = [ + configurations.sharedDependency, + httpClientInfo.dependencies, + configurations[configName] + ] as Set + + println "Version ${version}: Set implementation configuration" + } + } +} + +shadowJar { + zip64 = true +} + +// Set up default configuration when no specific version task is used +// e.g. ./gradlew build so that IDE can recognize import classes +afterEvaluate { + def effectiveVersion = project.ext.getEffectiveVersion() + + if (effectiveVersion == project.ext.defaultVersion) { + + def configName = project.ext.createVersionConfigurations(effectiveVersion) + def httpClientInfo = project.ext.getHttpClientInfo() + configurations.implementation.extendsFrom = [ + configurations.sharedDependency, + httpClientInfo.dependencies, + configurations[configName] + ] as Set + println "Default build: Set up version ${effectiveVersion} with ${httpClientInfo.clientName} client" + } +} diff --git a/development_guide.md b/development_guide.md index 956ce18..1109340 100644 --- a/development_guide.md +++ b/development_guide.md @@ -1,4 +1,14 @@ ## Development Guide + +This guide provides comprehensive information for developers who want to contribute to the OpenSearch SQL CLI project. + +- [Development Environment Set Up](#development-environment-set-up) +- [Code Architecture Details](#code-architecture-details) +- [Run CLI](#run-cli) +- [Testing](#testing) +- [Style](#style) +- [Release Guide](#release-guide) + ### Development Environment Set Up - `pip install virtualenv` - `virtualenv venv` to create virtual environment for **Python 3** @@ -6,25 +16,200 @@ - `cd` into project root folder. - `pip install --editable .` will install all dependencies from `setup.py`. -### Run CLI -- Start an OpenSearch instance from either local, Docker with OpenSearch SQL plugin, or AWS Elasticsearch -- To launch the cli, use 'wake' word `opensearchsql` followed by endpoint of your running OpenSearch instance. If not specifying -any endpoint, it uses http://localhost:9200 by default. If not provided with port number, http endpoint uses 9200 and -https uses 443 by default. +### Code Architecture Details + +#### Layered Architecture + +The OpenSearch SQL CLI uses a layered architecture to bridge Python's interactive capabilities with Java's robust OpenSearch client libraries: + +``` +Python CLI Layer → Py4J Bridge → Java Gateway → OpenSearch Client → OpenSearch Cluster +``` + +1. **Python CLI Layer** + - Handles user interaction, command parsing, and display formatting + - Manages configuration, history, and saved queries + - Key components: `main.py`, `interactive_shell.py`, `execute_query.py` + +2. **Py4J Bridge** + - Enables Python code to access Java objects + - Manages communication between Python and Java processes + - Key components: `sql_connection.py`, `sql_library_manager.py` + +3. **Java Gateway** + - Provides entry point for Python to access Java functionality + - Initializes connections to OpenSearch + - Key components: `Gateway.java`, `GatewayModule.java` + +4. **OpenSearch Client** + - Handles communication with OpenSearch cluster + - Executes queries and processes results + - Key components: `Client4.java`/`Client5.java`, `QueryExecution.java` + +#### Key Classes and Their Responsibilities + +##### Python Components + +- **OpenSearchSqlCli** (`main.py`): Entry point for the CLI, processes command-line arguments +- **InteractiveShell** (`interactive_shell.py`): Manages the interactive shell, command history, and user input +- **ExecuteQuery** (`execute_query.py`): Handles query execution and result formatting +- **SqlConnection** (`sql_connection.py`): Manages connection to the Java gateway +- **SqlLibraryManager** (`sql_library_manager.py`): Manages the Java process lifecycle +- **SqlVersion** (`sql_version.py`): Handles version detection and JAR file selection + +##### Java Components + +- **Gateway** (`Gateway.java`): Main entry point for Java functionality, exposed to Python via Py4J +- **GatewayModule** (`GatewayModule.java`): Guice module for dependency injection +- **QueryExecution** (`QueryExecution.java`): Executes queries against OpenSearch +- **Client4/Client5** (`Client4.java`/`Client5.java`): HTTP client implementations for different OpenSearch versions + +#### Version-Specific JAR Building + +The CLI supports multiple OpenSearch versions by dynamically building version-specific JARs. The build system automatically selects the appropriate HTTP client and its unified query packages based on the provided OpenSearch SQL version: +- For OpenSearch 3.x and above: Uses HTTP5 client +- For OpenSearch below 3.x: Uses HTTP4 client + +##### 1. Version Detection and Build Triggering + +The CLI supports three methods for specifying the SQL version: + +1. **Maven Repository Version** (e.g., `opensearchsql -v 3.1`): + - `sql_version.py` parses and normalizes it to a full version (e.g., `3.1.0.0`) + - The system checks if a corresponding JAR file exists (e.g., `opensearchsql-3.1.0.0.jar`) + - If not found, it automatically triggers the Gradle build process: + ```bash + # For OpenSearch SQL 3.1.0.0 + ./gradlew 3_1_0_0 + ``` + +2. **Local Directory** (e.g., `opensearchsql --local /path/to/sql/plugin/directory`): + - Uses a local directory containing the SQL plugin JAR files + - Extracts the version from the JAR filename + - Builds a local version-specific JAR: + ```bash + # For local OpenSearch SQL 3.1.0.0 + ./gradlew 3_1_0_0_local -PlocalJarDir=/path/to/sql/plugin/directory + ``` + +3. **Remote Git Repository** (e.g., `opensearchsql --remote https://github.com/opensearch-project/sql.git -b `): + - Clones the specified git repository and branch using: + ```bash + git clone --branch --single-branch + ``` + - Extracts the version from the cloned repository's JAR files + - Builds a local version-specific JAR using the cloned repository + ```bash + # For local OpenSearch SQL 3.1.0.0 + ./gradlew 3_1_0_0_local -PlocalJarDir=/project_root/remote/git_directory + ``` + +##### 2. Dynamic Gradle Task Creation + +The build system uses Gradle's task rules to dynamically create tasks based on version numbers: +- When `./gradlew 3_1_0_0` is executed, it creates configurations specific to version 3.1.0.0 +- The `createVersionConfigurations` function sets up dependencies and configurations +- This allows supporting any OpenSearch version without hardcoding version-specific tasks + +##### 3. HTTP Client Selection + +Based on the version, the system automatically selects the appropriate HTTP client: +- HTTP5 for OpenSearch 3.x and above +- HTTP4 for OpenSearch below 3.x + +This selection affects: +- Which source sets are compiled (`http4` or `http5`) +- Which dependencies are included +- How the client connects to OpenSearch + +##### 4. Dependencies Configuration + +The system adds shared dependencies and version-specific dependencies: +```bash +# Shared dependencies are added and its specific version dependency: +org.opensearch.query:unified-query-common:3.1.0.0-SNAPSHOT +org.opensearch.query:unified-query-core:3.1.0.0-SNAPSHOT +org.opensearch.query:unified-query-opensearch:3.1.0.0-SNAPSHOT +org.opensearch.query:unified-query-ppl:3.1.0.0-SNAPSHOT +org.opensearch.query:unified-query-sql:3.1.0.0-SNAPSHOT +org.opensearch.query:unified-query-protocol:3.1.0.0-SNAPSHOT +org.opensearch.query:unified-query-datasources:3.1.0.0-SNAPSHOT +org.opensearch.client:opensearch-rest-high-level-client:3.1.0 +org.opensearch.client:opensearch-rest-client:3.1.0 +org.opensearch.client:opensearch-java:3.1.0 +org.apache.httpcomponents.core5:httpcore5:5.2 +org.apache.httpcomponents.client5:httpclient5:5.2.1 +``` + +##### 5. Shadow JAR Creation + +The `createShadowJarTask` function creates a task to build a fat JAR with all dependencies: +- The resulting JAR is named with the specific version (e.g., `opensearchsql-3.1.0.0.jar`) +- This JAR includes all necessary dependencies for the specified version +- The JAR is then loaded by the Python process when the CLI runs + +Similarly, the `createLocalShadowJarTask` function creates a task for building a fat JAR using local JAR files: +- It accepts a local directory path containing the SQL plugin JAR files +- It includes the JAR files from the specified local directory +- The resulting JAR is named with the specific version and includes "_local" in the Gradle task name (e.g., `3_1_0_0_local`) + +This architecture allows the CLI to support multiple OpenSearch versions without requiring separate installations or complex configuration. + +## Run CLI +- Start an OpenSearch instance from either local, Docker with OpenSearch SQL plugin, or AWS OpenSearch +- To launch the cli, use 'wake' word `opensearchsql` followed by endpoint of your running OpenSearch instance. If not specifying any endpoint, it uses http://localhost:9200 by default. If not provided with port number, http endpoint uses 9200 and https uses 443 by default. + +### CLI Flow + +The OpenSearch SQL CLI follows this execution flow when processing queries: + +1. **Command Invocation**: `opensearchsql` command will run with its default settings + +2. **Initialization Process**: + - Version detection determines whether to use HTTP4 (OpenSearch < 3.x) or HTTP5 (OpenSearch ≥ 3.x) client + - Connection to OpenSearch cluster is verified + - Java Gateway server is started via `sql_library_manager` + - Appropriate JAR file is loaded based on OpenSearch version + +3. **Query Processing Flow**: + ``` + Input Query → Python → Java → OpenSearch → Java → Python → Output + ``` + + Detailed steps: + 1. User enters query in interactive shell + 2. `InteractiveShell.execute_query()` processes the input + 3. `ExecuteQuery.execute_query()` prepares the query + 4. `sql_connection.query_executor()` sends query to Java gateway + 5. `Gateway.queryExecution()` in Java receives the query + 6. `QueryExecution.execute()` processes the query: + - Determines if it's PPL or SQL + - Sends to appropriate service (pplService or sqlService) + - Formats results based on requested format (JSON, Table, CSV) + 7. Results are returned to Python and displayed to user + +4. **Component Interaction**: + - Python components use Py4J to communicate with Java + - Java components use OpenSearch client libraries to communicate with OpenSearch + - HTTP4 or HTTP5 client is used based on OpenSearch version -### Testing +## Testing - Prerequisites - Build the application - - Start a local OpenSearch instance with - [OpenSearch SQL plugin](https://opensearch.org/docs/latest/search-plugins/sql/sql/index/) installed - and listening at http://localhost:9200. + - Start a local OpenSearch instance. - Pytest - `pip install -r requirements-dev.txt` Install test frameworks including Pytest and mock. - - `cd` into `tests` and run `pytest` -- Refer to [test_plan](tests/test_plan.md) for manual test guidance. + - `cd` into `src/main/python/opensearchsql_cli/tests` and run `pytest` +- Refer to [README.md](src/main/python/opensearchsql_cli/tests/README.md) for manual test guidance. -### Style -- Use [black](https://github.com/psf/black) to format code, with option of `--line-length 120` +## Style +- Use [black](https://github.com/psf/black) to format code. +``` +# Format all Python files +black . +# Format all Java files +./gradlew spotlessApply +``` ## Release guide @@ -34,7 +219,7 @@ https uses 443 by default. ### Workflow 1. Update version number - 1. Modify the version number in `__init__.py` under `src` package. It will be used by `setup.py` for release. + 1. Modify the version number in [`__init__.py`](`src/main/python/opensearchsql_cli/__init__.py`). It will be used by `setup.py` for release. 2. Create/Update `setup.py` (if needed) 1. For more details refer to https://packaging.python.org/tutorials/packaging-projects/#creating-setup-py 3. Update README.md, Legal and copyright files(if needed) @@ -52,10 +237,10 @@ https uses 443 by default. 1. `pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple opensearchsql` 7. Upload to PyPI 1. Register an account on [PyPI](https://pypi.org/), note that these are two separate servers and the credentials from the test server are not shared with the main server. - 2. Use `twine upload dist/*` to upload your package and enter your credentials for the account you registered on PyPI.You don’t need to specify --repository; the package will upload to https://pypi.org/ by default. + 2. Use `twine upload dist/*` to upload your package and enter your credentials for the account you registered on PyPI. You don't need to specify --repository; the package will upload to https://pypi.org/ by default. 8. Install your package from PyPI using `pip install [your-package-name]` ### Reference - https://medium.com/@joel.barmettler/how-to-upload-your-python-package-to-pypi-65edc5fe9c56 - https://packaging.python.org/tutorials/packaging-projects/ -- https://packaging.python.org/guides/using-testpypi/ \ No newline at end of file +- https://packaging.python.org/guides/using-testpypi/ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ff23a68 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/requirements-dev.txt b/requirements-dev.txt index 6b76d69..f79b158 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,8 @@ -pytest==8.3.3 -mock==3.0.5 -pexpect==3.3 -twine==1.13.0 -tox>=1.9.2 -pytest-vcr>=1.0.2 \ No newline at end of file +# Python formatter +black==25.1.0 +# Tests +pytest==8.4.1 +mock==5.2.0 +vcrpy==7.0.0 +tox==4.28.4 +twine==6.1.0 \ No newline at end of file diff --git a/screenshots/usage.gif b/screenshots/usage.gif index 20299dd..c89d512 100644 Binary files a/screenshots/usage.gif and b/screenshots/usage.gif differ diff --git a/setup.py b/setup.py index ab29fb7..254bb8d 100644 --- a/setup.py +++ b/setup.py @@ -1,31 +1,44 @@ """ -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 +Setup script for OpenSearch SQL CLI """ import re import ast - from setuptools import setup, find_packages install_requirements = [ - "click == 7.1.2", - "prompt_toolkit == 2.0.6", - "Pygments == 2.15.1", - "cli_helpers[styles] == 2.3.1", - "opensearch-py == 1.0.0", - "pyfiglet == 0.8.post1", - "boto3 == 1.34.34", - "requests-aws4auth == 1.2.3", - "setuptools == 74.1.2", + # SQL CLI + "markdown-it-py==3.0.0", + "mdurl==0.1.2", + "prompt_toolkit==3.0.51", + "Pygments==2.19.2", + "pyfiglet==1.0.3", + "PyYAML==6.0.2", + "rich==14.0.0", + "shellingham==1.5.4", + "typer==0.16.0", + "typing_extensions==4.14.1", + "opensearch-py==3.0.0", + "requests==2.32.4", + "requests-aws4auth==1.3.1", + "boto3==1.39.8", + "beautifulsoup4==4.13.4", + "packaging==25.0", + "lxml==6.0.0", + "urllib3>=2.5.0", + # Java Gateway Python + "py4j==0.10.9.9", ] _version_re = re.compile(r"__version__\s+=\s+(.*)") -with open("src/opensearch_sql_cli/__init__.py", "rb") as f: - version = str(ast.literal_eval(_version_re.search(f.read().decode("utf-8")).group(1))) +with open("src/main/python/opensearchsql_cli/__init__.py", "rb") as f: + version = str( + ast.literal_eval(_version_re.search(f.read().decode("utf-8")).group(1)) + ) + -description = "OpenSearch SQL CLI with auto-completion and syntax highlighting" +description = "OpenSearch SQL CLI with SQL Plug-in Version Selection" with open("README.md", "r") as fh: long_description = fh.read() @@ -37,14 +50,14 @@ version=version, license="Apache 2.0", url="https://docs-beta.opensearch.org/search-plugins/sql/cli/", - packages=find_packages("src"), - package_dir={"": "src"}, - package_data={"opensearch_sql_cli": ["conf/clirc", "opensearch_literals/opensearch_literals.json"]}, + package_dir={"": "src/main/python"}, + packages=find_packages(where="src/main/python"), + include_package_data=True, description=description, long_description=long_description, long_description_content_type="text/markdown", install_requires=install_requirements, - entry_points={"console_scripts": ["opensearchsql=opensearch_sql_cli.main:cli"]}, + entry_points={"console_scripts": ["opensearchsql=opensearchsql_cli.main:main"]}, classifiers=[ "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", @@ -52,16 +65,13 @@ "Operating System :: POSIX :: Linux", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: SQL", "Topic :: Database", "Topic :: Database :: Front-Ends", "Topic :: Software Development", "Topic :: Software Development :: Libraries :: Python Modules", ], - python_requires=">=3.0", + python_requires=">=3.12", ) diff --git a/src/main/java/Config.java b/src/main/java/Config.java new file mode 100644 index 0000000..06e14a7 --- /dev/null +++ b/src/main/java/Config.java @@ -0,0 +1,160 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.commons.configuration2.YAMLConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.opensearch.common.unit.TimeValue; +import org.opensearch.sql.common.setting.Settings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Configuration handler for OpenSearch CLI Reads settings from the configuration file using Apache + * Commons Configuration + */ +public class Config { + private static final Logger logger = LoggerFactory.getLogger("Config"); + + // Config file path + private static final String PROJECT_ROOT = System.getProperty("user.dir"); + private static final String CONFIG_FILE = + PROJECT_ROOT + "/src/main/python/opensearchsql_cli/config/config.yaml"; + + private static YAMLConfiguration yamlConfig = null; + + /** + * Get settings for OpenSearch SQL Library + * + * @return Settings object with values from the config file + */ + public static Settings getSettings() { + // Read settings from config file + Map configSettings = readSettingsFromConfig(); + + return new Settings() { + @Override + public T getSettingValue(Settings.Key key) { + return (T) configSettings.get(key); + } + + @Override + public List> getSettings() { + return List.copyOf(configSettings.entrySet()); + } + }; + } + + /** + * Read settings from the OpenSearch SQL CLI configuration file + * + * @return Map of settings + */ + private static Map readSettingsFromConfig() { + // Default settings to use if config file is not available + Map defaultSettings = + Map.of( + Settings.Key.QUERY_SIZE_LIMIT, 200, + Settings.Key.FIELD_TYPE_TOLERANCE, true, + Settings.Key.CALCITE_ENGINE_ENABLED, true, + Settings.Key.CALCITE_FALLBACK_ALLOWED, true, + Settings.Key.CALCITE_PUSHDOWN_ENABLED, true, + Settings.Key.CALCITE_PUSHDOWN_ROWCOUNT_ESTIMATION_FACTOR, 1.0, + Settings.Key.SQL_CURSOR_KEEP_ALIVE, TimeValue.timeValueMinutes(1)); + + try { + // Load the YAML configuration + loadConfig(); + + // Create a mutable map to store settings + Map settings = new HashMap<>(defaultSettings); + + // Parse settings from config file + try { + // QUERY_SIZE_LIMIT + if (yamlConfig.containsKey("SqlSettings.QUERY_SIZE_LIMIT")) { + int value = yamlConfig.getInt("SqlSettings.QUERY_SIZE_LIMIT"); + settings.put(Settings.Key.QUERY_SIZE_LIMIT, value); + } + + // FIELD_TYPE_TOLERANCE + if (yamlConfig.containsKey("SqlSettings.FIELD_TYPE_TOLERANCE")) { + boolean value = yamlConfig.getBoolean("SqlSettings.FIELD_TYPE_TOLERANCE"); + settings.put(Settings.Key.FIELD_TYPE_TOLERANCE, value); + } + + // CALCITE_ENGINE_ENABLED + if (yamlConfig.containsKey("SqlSettings.CALCITE_ENGINE_ENABLED")) { + boolean value = yamlConfig.getBoolean("SqlSettings.CALCITE_ENGINE_ENABLED"); + settings.put(Settings.Key.CALCITE_ENGINE_ENABLED, value); + } + + // CALCITE_FALLBACK_ALLOWED + if (yamlConfig.containsKey("SqlSettings.CALCITE_FALLBACK_ALLOWED")) { + boolean value = yamlConfig.getBoolean("SqlSettings.CALCITE_FALLBACK_ALLOWED"); + settings.put(Settings.Key.CALCITE_FALLBACK_ALLOWED, value); + } + + // CALCITE_PUSHDOWN_ENABLED + if (yamlConfig.containsKey("SqlSettings.CALCITE_PUSHDOWN_ENABLED")) { + boolean value = yamlConfig.getBoolean("SqlSettings.CALCITE_PUSHDOWN_ENABLED"); + settings.put(Settings.Key.CALCITE_PUSHDOWN_ENABLED, value); + } + + // CALCITE_PUSHDOWN_ROWCOUNT_ESTIMATION_FACTOR + if (yamlConfig.containsKey("SqlSettings.CALCITE_PUSHDOWN_ROWCOUNT_ESTIMATION_FACTOR")) { + double value = + yamlConfig.getDouble("SqlSettings.CALCITE_PUSHDOWN_ROWCOUNT_ESTIMATION_FACTOR"); + settings.put(Settings.Key.CALCITE_PUSHDOWN_ROWCOUNT_ESTIMATION_FACTOR, value); + } + + // SQL_CURSOR_KEEP_ALIVE + if (yamlConfig.containsKey("SqlSettings.SQL_CURSOR_KEEP_ALIVE")) { + int minutes = yamlConfig.getInt("SqlSettings.SQL_CURSOR_KEEP_ALIVE"); + settings.put(Settings.Key.SQL_CURSOR_KEEP_ALIVE, TimeValue.timeValueMinutes(minutes)); + } + + } catch (Exception e) { + logger.error("Error parsing settings from config file: " + e.getMessage(), e); + } + + return settings; + } catch (Exception e) { + logger.error("Error reading config file: " + e.getMessage(), e); + return defaultSettings; + } + } + + /** Load the YAML configuration from file */ + private static void loadConfig() { + if (yamlConfig != null) { + return; + } + + try { + // Check if config file exists + File file = new File(CONFIG_FILE); + if (!file.exists()) { + logger.info("Config file not found: " + CONFIG_FILE); + yamlConfig = new YAMLConfiguration(); + return; + } + + logger.info("Found config file at: " + CONFIG_FILE); + yamlConfig = new YAMLConfiguration(); + try (FileReader reader = new FileReader(file)) { + yamlConfig.read(reader); + } + } catch (IOException | ConfigurationException e) { + logger.error("Error loading configuration: " + e.getMessage(), e); + yamlConfig = new YAMLConfiguration(); + } + } +} diff --git a/src/main/java/Gateway.java b/src/main/java/Gateway.java new file mode 100644 index 0000000..7ebcb8e --- /dev/null +++ b/src/main/java/Gateway.java @@ -0,0 +1,99 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import com.google.inject.Guice; +import com.google.inject.Injector; +import org.opensearch.sql.ppl.PPLService; +import org.opensearch.sql.sql.SQLService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import py4j.GatewayServer; +import query.QueryExecution; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; + +public class Gateway { + private static final Logger logger = LoggerFactory.getLogger("Gateway"); + + private PPLService pplService; + private SQLService sqlService; + private QueryExecution queryExecution; + + public boolean initializeAwsConnection(String hostPort, boolean useHttp5) { + // hostPort is the AWS OpenSearch endpoint (without https://) + Region region = new DefaultAwsRegionProviderChain().getRegion(); + + try { + + Injector injector = Guice.createInjector(new GatewayModule(hostPort, useHttp5)); + + // Initialize services + this.pplService = injector.getInstance(PPLService.class); + this.sqlService = injector.getInstance(SQLService.class); + this.queryExecution = injector.getInstance(QueryExecution.class); + + logger.info("Initialized AWS connection to OpenSearch at {} in region {}.", hostPort, region); + logger.info("Using HTTP{} for the connection.", (useHttp5 ? "5" : "4")); + + return true; + + } catch (Exception e) { + logger.error("Failed to initialize AWS connection", e); + return false; + } + } + + public boolean initializeConnection( + String host, + int port, + String protocol, + String username, + String password, + boolean ignoreSSL, + boolean useHttp5) { + + try { + + Injector injector = + Guice.createInjector( + new GatewayModule(host, port, protocol, username, password, ignoreSSL, useHttp5)); + + // Initialize services + this.pplService = injector.getInstance(PPLService.class); + this.sqlService = injector.getInstance(SQLService.class); + this.queryExecution = injector.getInstance(QueryExecution.class); + + logger.info("Initialized connection to OpenSearch at {}://{}:{}.", protocol, host, port); + logger.info("Using HTTP{} for the connection.", (useHttp5 ? "5" : "4")); + + return true; + + } catch (Exception e) { + logger.error("Failed to initialize connection", e); + return false; + } + } + + public String queryExecution(String query, boolean isPPL, boolean isExplain, String format) { + // Use the QueryExecution class to execute the query + return queryExecution.execute(query, isPPL, isExplain, format); + } + + public static void main(String[] args) { + try { + Gateway app = new Gateway(); + + // default port 25333 + int gatewayPort = 25333; + GatewayServer server = new GatewayServer(app, gatewayPort); + + server.start(); + logger.info("Gateway Server Started on port {}", gatewayPort); + + } catch (Exception e) { + logger.error("Failed to start Gateway Server", e); + } + } +} diff --git a/src/main/java/GatewayModule.java b/src/main/java/GatewayModule.java new file mode 100644 index 0000000..cf9e8d9 --- /dev/null +++ b/src/main/java/GatewayModule.java @@ -0,0 +1,332 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import client.ClientBuilder; +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.google.inject.name.Named; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.opensearch.sql.analysis.Analyzer; +import org.opensearch.sql.analysis.ExpressionAnalyzer; +import org.opensearch.sql.common.setting.Settings; +import org.opensearch.sql.datasource.DataSourceService; +import org.opensearch.sql.datasource.model.DataSourceMetadata; +import org.opensearch.sql.datasources.auth.DataSourceUserAuthorizationHelper; +import org.opensearch.sql.datasources.service.DataSourceMetadataStorage; +import org.opensearch.sql.datasources.service.DataSourceServiceImpl; +import org.opensearch.sql.executor.ExecutionEngine; +import org.opensearch.sql.executor.QueryManager; +import org.opensearch.sql.executor.QueryService; +import org.opensearch.sql.executor.execution.QueryPlanFactory; +import org.opensearch.sql.executor.pagination.PlanSerializer; +import org.opensearch.sql.expression.function.BuiltinFunctionRepository; +import org.opensearch.sql.monitor.AlwaysHealthyMonitor; +import org.opensearch.sql.monitor.ResourceMonitor; +import org.opensearch.sql.opensearch.client.OpenSearchClient; +import org.opensearch.sql.opensearch.executor.OpenSearchExecutionEngine; +import org.opensearch.sql.opensearch.executor.protector.ExecutionProtector; +import org.opensearch.sql.opensearch.executor.protector.OpenSearchExecutionProtector; +import org.opensearch.sql.opensearch.storage.OpenSearchDataSourceFactory; +import org.opensearch.sql.opensearch.storage.OpenSearchStorageEngine; +import org.opensearch.sql.planner.Planner; +import org.opensearch.sql.planner.optimizer.LogicalPlanOptimizer; +import org.opensearch.sql.ppl.PPLService; +import org.opensearch.sql.ppl.antlr.PPLSyntaxParser; +import org.opensearch.sql.protocol.response.QueryResult; +import org.opensearch.sql.protocol.response.format.CsvResponseFormatter; +import org.opensearch.sql.protocol.response.format.JdbcResponseFormatter; +import org.opensearch.sql.protocol.response.format.JsonResponseFormatter; +import org.opensearch.sql.protocol.response.format.RawResponseFormatter; +import org.opensearch.sql.protocol.response.format.ResponseFormatter; +import org.opensearch.sql.protocol.response.format.SimpleJsonResponseFormatter; +import org.opensearch.sql.sql.SQLService; +import org.opensearch.sql.sql.antlr.SQLSyntaxParser; +import org.opensearch.sql.storage.DataSourceFactory; +import org.opensearch.sql.storage.StorageEngine; +import query.CustomQueryManager; +import query.QueryExecution; + +public class GatewayModule extends AbstractModule { + private final String host; + private final int port; + private final String protocol; + private final String username; + private final String password; + private final boolean ignoreSSL; + private final boolean useAwsAuth; + private final String awsEndpoint; + private final String awsRegion; + private final boolean useHttp5; + + public GatewayModule( + String host, + int port, + String protocol, + String username, + String password, + boolean ignoreSSL, + boolean useHttp5) { + this.host = host; + this.port = port; + this.protocol = protocol; + this.username = username; + this.password = password; + this.ignoreSSL = ignoreSSL; + this.useAwsAuth = false; + this.awsEndpoint = null; + this.awsRegion = null; + this.useHttp5 = useHttp5; + } + + public GatewayModule(String awsEndpoint, boolean useHttp5) { + this.host = null; + this.port = 0; + this.protocol = null; + this.username = null; + this.password = null; + this.ignoreSSL = false; + this.useAwsAuth = true; + this.awsEndpoint = awsEndpoint; + this.awsRegion = null; + this.useHttp5 = useHttp5; + } + + @Override + protected void configure() {} + + @Provides + public OpenSearchClient openSearchClient() { + try { + // Use the ClientBuilder to create the appropriate client + ClientBuilder builder = new client.ClientBuilder().withHttp5(useHttp5); + + if (useAwsAuth) { + // Configure AWS authentication + return builder.withAwsAuth(awsEndpoint).build(); + } else { + // Configure standard authentication + return builder + .withHost(host) + .withPort(port) + .withProtocol(protocol) + .withUsername(username) + .withPassword(password) + .withIgnoreSSL(ignoreSSL) + .build(); + } + } catch (Exception e) { + throw new RuntimeException("Failed to create OpenSearchClient: " + e.getMessage(), e); + } + } + + @Provides + QueryManager queryManager(OpenSearchClient openSearchClient) { + return new CustomQueryManager(openSearchClient); + } + + @Provides + BuiltinFunctionRepository functionRepository() { + return BuiltinFunctionRepository.getInstance(); + } + + @Provides + ExpressionAnalyzer expressionAnalyzer(BuiltinFunctionRepository functionRepository) { + return new ExpressionAnalyzer(functionRepository); + } + + @Provides + Settings settings() { + // Get settings from the configuration file: main/config/config_file + return Config.getSettings(); + } + + @Provides + OpenSearchDataSourceFactory openSearchDataSourceFactory( + OpenSearchClient client, Settings settings) { + return new OpenSearchDataSourceFactory(client, settings); + } + + @Provides + Set dataSourceFactories(OpenSearchDataSourceFactory factory) { + return Set.of(factory); + } + + @Provides + public DataSourceMetadataStorage dataSourceMetadataStorage() { + return new DataSourceMetadataStorage() { + @Override + public List getDataSourceMetadata() { + return Collections.emptyList(); + } + + @Override + public Optional getDataSourceMetadata(String datasourceName) { + return Optional.empty(); + } + + @Override + public void createDataSourceMetadata(DataSourceMetadata dataSourceMetadata) {} + + @Override + public void updateDataSourceMetadata(DataSourceMetadata dataSourceMetadata) {} + + @Override + public void deleteDataSourceMetadata(String datasourceName) {} + }; + } + + @Provides + public DataSourceUserAuthorizationHelper getDataSourceUserRoleHelper() { + return new DataSourceUserAuthorizationHelper() { + @Override + public void authorizeDataSource(DataSourceMetadata dataSourceMetadata) {} + }; + } + + @Provides + DataSourceService dataSourceService( + Set factories, + DataSourceMetadataStorage metadataStorage, + DataSourceUserAuthorizationHelper authorizationHelper) { + return new DataSourceServiceImpl(factories, metadataStorage, authorizationHelper); + } + + @Provides + Analyzer analyzer( + ExpressionAnalyzer expressionAnalyzer, + DataSourceService dataSourceService, + BuiltinFunctionRepository functionRepository) { + return new Analyzer(expressionAnalyzer, dataSourceService, functionRepository); + } + + @Provides + ResourceMonitor resourceMonitor() { + return new AlwaysHealthyMonitor(); + } + + @Provides + ExecutionProtector executionProtector(ResourceMonitor resourceMonitor) { + return new OpenSearchExecutionProtector(resourceMonitor); + } + + @Provides + StorageEngine storageEngine(OpenSearchClient client, Settings settings) { + return new OpenSearchStorageEngine(client, settings); + } + + @Provides + PlanSerializer planSerializer(StorageEngine storageEngine) { + return new PlanSerializer(storageEngine); + } + + @Provides + ExecutionEngine executionEngine( + OpenSearchClient client, ExecutionProtector protector, PlanSerializer planSerializer) { + return new OpenSearchExecutionEngine(client, protector, planSerializer); + } + + @Provides + Planner planner() { + return new Planner(LogicalPlanOptimizer.create()); + } + + @Provides + QueryService queryService( + Analyzer analyzer, + ExecutionEngine executionEngine, + Planner planner, + DataSourceService dataSourceService, + Settings settings) { + return new QueryService(analyzer, executionEngine, planner, dataSourceService, settings); + } + + @Provides + QueryPlanFactory queryPlanFactory(QueryService queryService) { + return new QueryPlanFactory(queryService); + } + + @Provides + PPLService pplService( + PPLSyntaxParser pplSyntaxParser, + QueryManager queryManager, + QueryPlanFactory queryPlanFactory, + Settings settings) { + return new PPLService(new PPLSyntaxParser(), queryManager, queryPlanFactory, settings); + } + + @Provides + SQLService sqlService( + SQLSyntaxParser sqlSyntaxParser, + QueryManager queryManager, + QueryPlanFactory queryPlanFactory) { + return new SQLService(new SQLSyntaxParser(), queryManager, queryPlanFactory); + } + + @Provides + QueryExecution queryExecution(PPLService pplService, SQLService sqlService) { + return new QueryExecution(pplService, sqlService); + } + + @Provides + public CsvResponseFormatter csvResponseFormatter() { + return new CsvResponseFormatter(); + } + + @Provides + @Named("pretty") + public SimpleJsonResponseFormatter jsonResponseFormatter() { + return new SimpleJsonResponseFormatter(JsonResponseFormatter.Style.PRETTY); + } + + @Provides + @Named("compact") + public SimpleJsonResponseFormatter compactJsonResponseFormatter() { + return new SimpleJsonResponseFormatter(JsonResponseFormatter.Style.COMPACT); + } + + @Provides + public JdbcResponseFormatter jdbcResponseFormatter() { + return new JdbcResponseFormatter(JsonResponseFormatter.Style.PRETTY); + } + + @Provides + public RawResponseFormatter rawResponseFormatter() { + return new RawResponseFormatter(); + } + + @Provides + public ResponseFormatter getFormatter( + String formatName, + @Named("pretty") SimpleJsonResponseFormatter prettyFormatter, + @Named("compact") SimpleJsonResponseFormatter compactFormatter, + CsvResponseFormatter csvFormatter, + JdbcResponseFormatter jdbcFormatter, + RawResponseFormatter rawFormatter) { + if (formatName == null || formatName.isEmpty()) { + // Default to JSON + return prettyFormatter; + } + + switch (formatName.toLowerCase()) { + case "csv": + return csvFormatter; + case "json": + return prettyFormatter; + case "compact_json": + return compactFormatter; + case "jdbc": + return jdbcFormatter; + case "raw": + return rawFormatter; + case "table": + return jdbcFormatter; + default: + return prettyFormatter; + } + } +} diff --git a/src/main/java/client/ClientBuilder.java b/src/main/java/client/ClientBuilder.java new file mode 100644 index 0000000..27b9e33 --- /dev/null +++ b/src/main/java/client/ClientBuilder.java @@ -0,0 +1,157 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package client; + +import java.lang.reflect.Method; +import org.opensearch.sql.opensearch.client.OpenSearchClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Builder class for creating OpenSearchClient instances. Supports both HTTP4 and HTTP5 client + * implementations. + */ +public class ClientBuilder { + private static final Logger logger = LoggerFactory.getLogger(ClientBuilder.class); + + // Client configuration + private String host; + private int port; + private String protocol; + private String username; + private String password; + private boolean ignoreSSL; + private boolean useAwsAuth; + private String awsEndpoint; + private boolean useHttp5; + + /** + * Sets the host for the client. + * + * @param host the host name or IP address + * @return this builder instance + */ + public ClientBuilder withHost(String host) { + this.host = host; + return this; + } + + /** + * Sets the port for the client. + * + * @param port the port number + * @return this builder instance + */ + public ClientBuilder withPort(int port) { + this.port = port; + return this; + } + + /** + * Sets the protocol for the client (http or https). + * + * @param protocol the protocol + * @return this builder instance + */ + public ClientBuilder withProtocol(String protocol) { + this.protocol = protocol; + return this; + } + + /** + * Sets the username for basic authentication. + * + * @param username the username + * @return this builder instance + */ + public ClientBuilder withUsername(String username) { + this.username = username; + return this; + } + + /** + * Sets the password for basic authentication. + * + * @param password the password + * @return this builder instance + */ + public ClientBuilder withPassword(String password) { + this.password = password; + return this; + } + + /** + * Sets whether to ignore SSL certificate validation. + * + * @param ignoreSSL true to ignore SSL certificate validation + * @return this builder instance + */ + public ClientBuilder withIgnoreSSL(boolean ignoreSSL) { + this.ignoreSSL = ignoreSSL; + return this; + } + + /** + * Configures the client to use AWS authentication. + * + * @param awsEndpoint the AWS endpoint + * @return this builder instance + */ + public ClientBuilder withAwsAuth(String awsEndpoint) { + this.useAwsAuth = true; + this.awsEndpoint = awsEndpoint; + return this; + } + + /** + * Sets whether to use HTTP5 client implementation. + * + * @param useHttp5 true to use HTTP5, false to use HTTP4 + * @return this builder instance + */ + public ClientBuilder withHttp5(boolean useHttp5) { + this.useHttp5 = useHttp5; + return this; + } + + /** + * Builds and returns an OpenSearchClient instance based on the configured parameters. Uses + * reflection to create the appropriate client (HTTP4 or HTTP5) based on configuration. + * + * @return an OpenSearchClient instance + * @throws RuntimeException if client creation fails + */ + public OpenSearchClient build() { + try { + // Determine which client class to use based on useHttp5 flag + String clientClassName = useHttp5 ? "client.http5.Http5Client" : "client.http4.Http4Client"; + Class clientClass = Class.forName(clientClassName); + + if (useAwsAuth) { + // Call createAwsClient(awsEndpoint) + logger.info("Building AWS client with endpoint: {}", awsEndpoint); + Method method = clientClass.getMethod("createAwsClient", String.class); + return (OpenSearchClient) method.invoke(null, awsEndpoint); + } else { + // Call createClient(host, port, protocol, username, password, ignoreSSL) + logger.info("Building {} client for {}:{}", protocol.toUpperCase(), host, port); + Method method = + clientClass.getMethod( + "createClient", + String.class, + int.class, + String.class, + String.class, + String.class, + boolean.class); + return (OpenSearchClient) + method.invoke(null, host, port, protocol, username, password, ignoreSSL); + } + } catch (Exception e) { + throw new RuntimeException("Failed to create OpenSearchClient: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/client/http4/Http4Client.java b/src/main/java/client/http4/Http4Client.java new file mode 100644 index 0000000..3dd0636 --- /dev/null +++ b/src/main/java/client/http4/Http4Client.java @@ -0,0 +1,221 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package client.http4; + +import io.github.acm19.aws.interceptor.http.AwsRequestSigningApacheInterceptor; +import javax.net.ssl.SSLContext; +import org.apache.http.Header; +import org.apache.http.HttpHost; +import org.apache.http.HttpRequest; +import org.apache.http.HttpRequestInterceptor; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.conn.ssl.TrustStrategy; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.nio.client.HttpAsyncClientBuilder; +import org.apache.http.protocol.HttpContext; +import org.apache.http.ssl.SSLContexts; +import org.opensearch.client.RestClient; +import org.opensearch.client.RestClientBuilder; +import org.opensearch.client.RestHighLevelClient; +import org.opensearch.sql.opensearch.client.OpenSearchClient; +import org.opensearch.sql.opensearch.client.OpenSearchRestClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; + +/** + * Client class for creating OpenSearch clients with different authentication methods using HTTP4 + * for OpenSearch SQL plug-in version 2 + */ +public class Http4Client { + private static final Logger logger = LoggerFactory.getLogger("Http4Client"); + + private static final String SERVERLESS = "aos"; + private static final String SERVICE = "es"; + private static final String SERVERLESS_NAME = "aoss"; + private static final String SERVICE_NAME = "es"; + + /** + * Creates an OpenSearch client with AWS authentication (SigV4). + * + * @param awsEndpoint The AWS OpenSearch endpoint URL + * @return Configured OpenSearchClient with AWS authentication + * @throws RuntimeException if client creation fails + */ + public static OpenSearchClient createAwsClient(String awsEndpoint) { + try { + String serviceName = determineServiceName(awsEndpoint); + AwsCredentialsProvider credentialsProvider = createAwsCredentialsProvider(); + Region region = getAwsRegion(); + + HttpHost host = new HttpHost(awsEndpoint, 443, "https"); + HttpRequestInterceptor awsInterceptor = + createAwsSigningInterceptor(serviceName, credentialsProvider, region); + + RestClientBuilder restClientBuilder = + RestClient.builder(host) + .setHttpClientConfigCallback( + httpClientBuilder -> configureAwsHttpClient(httpClientBuilder, awsInterceptor)); + + RestHighLevelClient restHighLevelClient = new RestHighLevelClient(restClientBuilder); + return new OpenSearchRestClient(restHighLevelClient); + } catch (Exception e) { + throw new RuntimeException("Failed to create AWS OpenSearchClient", e); + } + } + + /** + * Creates an OpenSearch client with HTTP or HTTPS and optional basic authentication. + * + * @param host The hostname + * @param port The port number + * @param protocol The protocol ("http" or "https") + * @param username The username for basic auth (can be null) + * @param password The password for basic auth (can be null) + * @param ignoreSSL Whether to ignore SSL certificate validation (only applies to HTTPS) + * @return Configured OpenSearchClient + * @throws RuntimeException if client creation fails + */ + public static OpenSearchClient createClient( + String host, int port, String protocol, String username, String password, boolean ignoreSSL) { + try { + boolean useHttps = "https".equalsIgnoreCase(protocol); + HttpHost httpHost = new HttpHost(host, port, useHttps ? "https" : "http"); + + RestClientBuilder restClientBuilder = RestClient.builder(httpHost); + + if (useHttps) { + // HTTPS configuration + CredentialsProvider credentialsProvider = + createBasicCredentialsProvider(httpHost, username, password); + SSLContext sslContext = createSSLContext(ignoreSSL); + + restClientBuilder.setHttpClientConfigCallback( + httpClientBuilder -> + configureHttpsClient(httpClientBuilder, credentialsProvider, sslContext)); + } else { + // HTTP configuration + restClientBuilder.setHttpClientConfigCallback( + httpClientBuilder -> configureHttpClient(httpClientBuilder)); + } + + RestHighLevelClient restHighLevelClient = new RestHighLevelClient(restClientBuilder); + return new OpenSearchRestClient(restHighLevelClient); + } catch (Exception e) { + throw new RuntimeException("Failed to create OpenSearchClient", e); + } + } + + /** Determines the AWS service name based on the endpoint URL. */ + private static String determineServiceName(String awsEndpoint) { + if (awsEndpoint.contains(SERVERLESS)) { + logger.info("Using service name '{}' for OpenSearch Serverless", SERVERLESS_NAME); + return SERVERLESS_NAME; + } else if (awsEndpoint.contains(SERVICE)) { + logger.info("Using service name '{}' for OpenSearch Service", SERVICE_NAME); + return SERVICE_NAME; + } else { + logger.error("Cannot determine service type from endpoint: {}", awsEndpoint); + throw new RuntimeException("Cannot determine service type"); + } + } + + /** Creates AWS credentials provider and logs access key ID. */ + private static AwsCredentialsProvider createAwsCredentialsProvider() { + AwsCredentialsProvider credentialsProvider = DefaultCredentialsProvider.builder().build(); + AwsCredentials credentials = credentialsProvider.resolveCredentials(); + logger.info("Access Key ID: {}", credentials.accessKeyId()); + return credentialsProvider; + } + + /** Gets AWS region from default provider chain. */ + private static Region getAwsRegion() { + Region region = new DefaultAwsRegionProviderChain().getRegion(); + logger.info("Using AWS region: {}", region); + return region; + } + + /** Creates AWS signing interceptor. */ + private static HttpRequestInterceptor createAwsSigningInterceptor( + String serviceName, AwsCredentialsProvider credentialsProvider, Region region) { + return new AwsRequestSigningApacheInterceptor( + serviceName, AwsV4HttpSigner.create(), credentialsProvider, region); + } + + /** Creates basic credentials provider for HTTPS authentication. */ + private static CredentialsProvider createBasicCredentialsProvider( + HttpHost httpHost, String username, String password) { + CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + if (username != null && password != null) { + credentialsProvider.setCredentials( + new AuthScope(httpHost), new UsernamePasswordCredentials(username, password)); + } + return credentialsProvider; + } + + /** Creates SSL context with optional certificate validation bypass. */ + private static SSLContext createSSLContext(boolean ignoreSSL) throws Exception { + TrustStrategy trustStrategy = (chains, authType) -> ignoreSSL; + return SSLContexts.custom().loadTrustMaterial(null, trustStrategy).build(); + } + + /** Configures HTTP client for AWS authentication. */ + private static HttpAsyncClientBuilder configureAwsHttpClient( + HttpAsyncClientBuilder httpClientBuilder, HttpRequestInterceptor awsInterceptor) { + return httpClientBuilder + .addInterceptorLast(awsInterceptor) + .addInterceptorLast(createLoggingInterceptor(true)); + } + + /** Configures HTTP client for HTTPS with basic authentication. */ + private static HttpAsyncClientBuilder configureHttpsClient( + HttpAsyncClientBuilder httpClientBuilder, + CredentialsProvider credentialsProvider, + SSLContext sslContext) { + return httpClientBuilder + .setDefaultCredentialsProvider(credentialsProvider) + .setSSLContext(sslContext) + .addInterceptorLast(createLoggingInterceptor(true)); + } + + /** Configures HTTP client for plain HTTP. */ + private static HttpAsyncClientBuilder configureHttpClient( + HttpAsyncClientBuilder httpClientBuilder) { + return httpClientBuilder.addInterceptorLast(createLoggingInterceptor(false)); + } + + /** + * Creates a logging interceptor for HTTP requests. + * + * @param isHttps Whether this is for HTTPS requests + * @return Configured logging interceptor + */ + private static HttpRequestInterceptor createLoggingInterceptor(boolean isHttps) { + final String protocol = isHttps ? "HTTPS" : "HTTP"; + return new HttpRequestInterceptor() { + @Override + public void process(HttpRequest request, HttpContext context) { + logger.info("===== {} REQUEST =====", protocol); + logger.info("Method: {}", request.getRequestLine().getMethod()); + logger.info("URI: {}", request.getRequestLine().getUri()); + logger.info("Request Type: {}", request.getClass().getSimpleName()); + + // Log headers + logger.info("Headers:"); + for (Header header : request.getAllHeaders()) { + logger.info("{}: {}", header.getName(), header.getValue()); + } + } + }; + } +} diff --git a/src/main/java/client/http5/Http5Client.java b/src/main/java/client/http5/Http5Client.java new file mode 100644 index 0000000..79a9ff9 --- /dev/null +++ b/src/main/java/client/http5/Http5Client.java @@ -0,0 +1,277 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package client.http5; + +import client.http5.aws.AwsRequestSigningApacheV5Interceptor; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; +import org.apache.hc.core5.function.Factory; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.reactor.ssl.TlsDetails; +import org.apache.hc.core5.ssl.SSLContextBuilder; +import org.opensearch.client.RestClient; +import org.opensearch.client.RestClientBuilder; +import org.opensearch.client.RestHighLevelClient; +import org.opensearch.sql.opensearch.client.OpenSearchClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; + +/** + * Client class for creating OpenSearch clients with different authentication methods using HTTP5 + * for OpenSearch SQL plug-in version 3 + */ +public class Http5Client { + private static final Logger logger = LoggerFactory.getLogger("Http5Client"); + + private static final String SERVERLESS = "aos"; + private static final String SERVICE = "es"; + private static final String SERVERLESS_NAME = "aoss"; + private static final String SERVICE_NAME = "es"; + + private static final String PROBLEMATIC_SHOW_URI = + "/?ignore_throttled=false&ignore_unavailable=false&expand_wildcards=open%2Cclosed&allow_no_indices=false&cluster_manager_timeout=30s"; + private static final String CORRECTED_SHOW_URI = "/*?"; + + /** + * Creates an OpenSearch client with AWS authentication (SigV4). + * + * @param awsEndpoint The AWS OpenSearch endpoint URL + * @return Configured OpenSearchClient with AWS authentication + * @throws RuntimeException if client creation fails + */ + public static OpenSearchClient createAwsClient(String awsEndpoint) { + try { + String serviceName = determineServiceName(awsEndpoint); + AwsCredentialsProvider credentialsProvider = createAwsCredentialsProvider(); + Region region = getAwsRegion(); + + HttpHost host = new HttpHost("https", awsEndpoint, 443); + HttpRequestInterceptor awsInterceptor = + createAwsSigningInterceptor(serviceName, credentialsProvider, region); + + RestClientBuilder restClientBuilder = + RestClient.builder(host) + .setHttpClientConfigCallback( + httpClientBuilder -> configureAwsHttpClient(httpClientBuilder, awsInterceptor)); + + RestHighLevelClient restHighLevelClient = new RestHighLevelClient(restClientBuilder); + return new OpenSearchRestClientImpl(restHighLevelClient); + } catch (Exception e) { + throw new RuntimeException("Failed to create AWS OpenSearchClient", e); + } + } + + /** + * Creates an OpenSearch client with HTTP or HTTPS and optional basic authentication. + * + * @param host The hostname + * @param port The port number + * @param protocol The protocol ("http" or "https") + * @param username The username for basic auth (can be null) + * @param password The password for basic auth (can be null) + * @param ignoreSSL Whether to ignore SSL certificate validation (only applies to HTTPS) + * @return Configured OpenSearchClient + * @throws RuntimeException if client creation fails + */ + public static OpenSearchClient createClient( + String host, int port, String protocol, String username, String password, boolean ignoreSSL) { + try { + boolean useHttps = "https".equalsIgnoreCase(protocol); + HttpHost httpHost = new HttpHost(useHttps ? "https" : "http", host, port); + + RestClientBuilder restClientBuilder = RestClient.builder(httpHost); + + if (useHttps) { + // HTTPS configuration + BasicCredentialsProvider credentialsProvider = + createBasicCredentialsProvider(httpHost, username, password); + SSLContext sslContext = createSSLContext(ignoreSSL); + PoolingAsyncClientConnectionManager connectionManager = createConnectionManager(sslContext); + + restClientBuilder.setHttpClientConfigCallback( + httpClientBuilder -> + configureHttpsClient(httpClientBuilder, credentialsProvider, connectionManager)); + } else { + // HTTP configuration + restClientBuilder.setHttpClientConfigCallback( + httpClientBuilder -> configureHttpClient(httpClientBuilder)); + } + + RestHighLevelClient restHighLevelClient = new RestHighLevelClient(restClientBuilder); + return new OpenSearchRestClientImpl(restHighLevelClient); + } catch (Exception e) { + throw new RuntimeException("Failed to create OpenSearchClient", e); + } + } + + /** Determines the AWS service name based on the endpoint URL. */ + private static String determineServiceName(String awsEndpoint) { + if (awsEndpoint.contains(SERVERLESS)) { + logger.info("Using service name '{}' for OpenSearch Serverless", SERVERLESS_NAME); + return SERVERLESS_NAME; + } else if (awsEndpoint.contains(SERVICE)) { + logger.info("Using service name '{}' for OpenSearch Service", SERVICE_NAME); + return SERVICE_NAME; + } else { + logger.error("Cannot determine service type from endpoint: {}", awsEndpoint); + throw new RuntimeException("Cannot determine service type"); + } + } + + /** Creates AWS credentials provider and logs access key ID. */ + private static AwsCredentialsProvider createAwsCredentialsProvider() { + AwsCredentialsProvider credentialsProvider = DefaultCredentialsProvider.builder().build(); + AwsCredentials credentials = credentialsProvider.resolveCredentials(); + logger.info("Access Key ID: {}", credentials.accessKeyId()); + return credentialsProvider; + } + + /** Gets AWS region from default provider chain. */ + private static Region getAwsRegion() { + Region region = new DefaultAwsRegionProviderChain().getRegion(); + logger.info("Using AWS region: {}", region); + return region; + } + + /** Creates AWS signing interceptor. */ + private static HttpRequestInterceptor createAwsSigningInterceptor( + String serviceName, AwsCredentialsProvider credentialsProvider, Region region) { + return new AwsRequestSigningApacheV5Interceptor( + serviceName, AwsV4HttpSigner.create(), credentialsProvider, region); + } + + /** Creates basic credentials provider for HTTPS authentication. */ + private static BasicCredentialsProvider createBasicCredentialsProvider( + HttpHost httpHost, String username, String password) { + BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + if (username != null && password != null) { + credentialsProvider.setCredentials( + new AuthScope(httpHost), + new UsernamePasswordCredentials(username, password.toCharArray())); + } + return credentialsProvider; + } + + /** Creates SSL context with optional certificate validation bypass. */ + private static SSLContext createSSLContext(boolean ignoreSSL) throws Exception { + return SSLContextBuilder.create() + .loadTrustMaterial(null, (chains, authType) -> ignoreSSL) + .build(); + } + + /** Creates connection manager with TLS strategy. */ + private static PoolingAsyncClientConnectionManager createConnectionManager( + SSLContext sslContext) { + Factory tlsDetailsFactory = + sslEngine -> new TlsDetails(sslEngine.getSession(), sslEngine.getApplicationProtocol()); + + TlsStrategy tlsStrategy = + ClientTlsStrategyBuilder.create() + .setSslContext(sslContext) + .setTlsDetailsFactory(tlsDetailsFactory) + .build(); + + return PoolingAsyncClientConnectionManagerBuilder.create().setTlsStrategy(tlsStrategy).build(); + } + + /** Configures HTTP client for AWS authentication. */ + private static HttpAsyncClientBuilder configureAwsHttpClient( + HttpAsyncClientBuilder httpClientBuilder, HttpRequestInterceptor awsInterceptor) { + return httpClientBuilder + .addRequestInterceptorFirst(createUriModificationInterceptor()) + .addRequestInterceptorLast(awsInterceptor) + .addRequestInterceptorLast(createLoggingInterceptor(true)); + } + + /** Configures HTTP client for HTTPS with basic authentication. */ + private static HttpAsyncClientBuilder configureHttpsClient( + HttpAsyncClientBuilder httpClientBuilder, + BasicCredentialsProvider credentialsProvider, + PoolingAsyncClientConnectionManager connectionManager) { + return httpClientBuilder + .setDefaultCredentialsProvider(credentialsProvider) + .setConnectionManager(connectionManager) + .addRequestInterceptorFirst(createUriModificationInterceptor()) + .addRequestInterceptorLast(createLoggingInterceptor(true)); + } + + /** Configures HTTP client for plain HTTP. */ + private static HttpAsyncClientBuilder configureHttpClient( + HttpAsyncClientBuilder httpClientBuilder) { + return httpClientBuilder + .addRequestInterceptorFirst(createUriModificationInterceptor()) + .addRequestInterceptorLast(createLoggingInterceptor(false)); + } + + /** + * Creates a URI modification interceptor for SHOW command. + * + *

Fixes the issue where certain query parameters are not recognized by OpenSearch. Original + * problematic URI contains parameters like allow_no_indices, cluster_manager_timeout, etc. These + * are replaced with a simplified URI pattern. + */ + private static HttpRequestInterceptor createUriModificationInterceptor() { + return new HttpRequestInterceptor() { + @Override + public void process(HttpRequest request, EntityDetails entityDetails, HttpContext context) { + try { + String originalUri = request.getRequestUri(); + logger.info("Original URI: {}", originalUri); + + if (PROBLEMATIC_SHOW_URI.equals(originalUri)) { + request.setPath(CORRECTED_SHOW_URI); + logger.info("Modified Show URI: {}", request.getRequestUri()); + } + } catch (Exception e) { + logger.error("Error modifying URI: {}", e.getMessage()); + } + } + }; + } + + /** + * Creates a logging interceptor for HTTP requests. + * + * @param isHttps Whether this is for HTTPS requests + * @return Configured logging interceptor + */ + private static HttpRequestInterceptor createLoggingInterceptor(boolean isHttps) { + final String protocol = isHttps ? "HTTPS" : "HTTP"; + return new HttpRequestInterceptor() { + @Override + public void process(HttpRequest request, EntityDetails entityDetails, HttpContext context) { + logger.info("===== {} REQUEST =====", protocol); + logger.info("Method: {}", request.getMethod()); + logger.info("URI: {}", request.getRequestUri()); + logger.info("Request Type: {}", request.getClass().getSimpleName()); + + // Log headers + logger.info("Headers:"); + request + .headerIterator() + .forEachRemaining(header -> logger.info("{}: {}", header.getName(), header.getValue())); + } + }; + } +} diff --git a/src/main/java/client/http5/OpenSearchRestClientImpl.java b/src/main/java/client/http5/OpenSearchRestClientImpl.java new file mode 100644 index 0000000..ab82751 --- /dev/null +++ b/src/main/java/client/http5/OpenSearchRestClientImpl.java @@ -0,0 +1,349 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package client.http5; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.opensearch.action.admin.cluster.settings.ClusterGetSettingsRequest; +import org.opensearch.action.admin.indices.settings.get.GetSettingsRequest; +import org.opensearch.action.admin.indices.settings.get.GetSettingsResponse; +import org.opensearch.action.search.*; +import org.opensearch.client.RequestOptions; +import org.opensearch.client.RestHighLevelClient; +import org.opensearch.client.indices.CreateIndexRequest; +import org.opensearch.client.indices.GetIndexRequest; +import org.opensearch.client.indices.GetIndexResponse; +import org.opensearch.client.indices.GetMappingsRequest; +import org.opensearch.client.indices.GetMappingsResponse; +import org.opensearch.cluster.metadata.AliasMetadata; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.search.builder.PointInTimeBuilder; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.search.sort.SortOrder; +import org.opensearch.sql.opensearch.client.OpenSearchClient; +import org.opensearch.sql.opensearch.mapping.IndexMapping; +import org.opensearch.sql.opensearch.request.OpenSearchQueryRequest; +import org.opensearch.sql.opensearch.request.OpenSearchRequest; +import org.opensearch.sql.opensearch.request.OpenSearchScrollRequest; +import org.opensearch.sql.opensearch.response.OpenSearchResponse; +import org.opensearch.transport.client.node.NodeClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * OpenSearch REST client to support standalone mode that runs entire engine from remote. + * + *

TODO: Support for authN and authZ with AWS Sigv4 or security plugin. + */ +public class OpenSearchRestClientImpl implements OpenSearchClient { + private static final Logger logger = LoggerFactory.getLogger("OpenSearchRestClientImpl"); + + /** OpenSearch high level REST client. */ + private final RestHighLevelClient client; + + public OpenSearchRestClientImpl(RestHighLevelClient client) { + this.client = client; + } + + @Override + public boolean exists(String indexName) { + logger.info("Checking if index exists: {}", indexName); + try { + return client.indices().exists(new GetIndexRequest(indexName), RequestOptions.DEFAULT); + } catch (IOException e) { + throw new IllegalStateException("Failed to check if index [" + indexName + "] exist", e); + } + } + + @Override + public void createIndex(String indexName, Map mappings) { + logger.info("Creating index: {}", indexName); + try { + client + .indices() + .create(new CreateIndexRequest(indexName).mapping(mappings), RequestOptions.DEFAULT); + } catch (IOException e) { + throw new IllegalStateException("Failed to create index [" + indexName + "]", e); + } + } + + @Override + public Map getIndexMappings(String... indexExpression) { + logger.info("Getting index mappings for: {}", Arrays.toString(indexExpression)); + GetMappingsRequest request = new GetMappingsRequest().indices(indexExpression); + try { + GetMappingsResponse response = client.indices().getMapping(request, RequestOptions.DEFAULT); + return response.mappings().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> new IndexMapping(e.getValue()))); + } catch (IOException e) { + throw new IllegalStateException("Failed to get index mappings for " + indexExpression, e); + } + } + + @Override + public Map getIndexMaxResultWindows(String... indexExpression) { + logger.info("Getting max result windows for: {}", Arrays.toString(indexExpression)); + GetSettingsRequest request = + new GetSettingsRequest().indices(indexExpression).includeDefaults(true); + try { + GetSettingsResponse response = client.indices().getSettings(request, RequestOptions.DEFAULT); + Map settings = response.getIndexToSettings(); + Map defaultSettings = response.getIndexToDefaultSettings(); + Map result = new HashMap<>(); + + defaultSettings.forEach( + (key, value) -> { + Integer maxResultWindow = value.getAsInt("index.max_result_window", null); + if (maxResultWindow != null) { + result.put(key, maxResultWindow); + } + }); + + settings.forEach( + (key, value) -> { + Integer maxResultWindow = value.getAsInt("index.max_result_window", null); + if (maxResultWindow != null) { + result.put(key, maxResultWindow); + } + }); + + return result; + } catch (IOException e) { + throw new IllegalStateException("Failed to get max result window for " + indexExpression, e); + } + } + + @Override + public OpenSearchResponse search(OpenSearchRequest request) { + logger.info("Search request type: {}", request.getClass().getSimpleName()); + + if (request instanceof OpenSearchScrollRequest) { + OpenSearchScrollRequest scrollRequest = (OpenSearchScrollRequest) request; + logger.info( + "Scroll request - Index names: {}", + Arrays.toString(scrollRequest.getIndexName().getIndexNames())); + } else if (request instanceof OpenSearchQueryRequest) { + OpenSearchQueryRequest queryRequest = (OpenSearchQueryRequest) request; + logger.info( + "Query request - Index names: {}", + Arrays.toString(queryRequest.getIndexName().getIndexNames())); + + // Get the source builder and save it to a file for the AWS interceptor to use + // Set up similar to OpenSearchQueryRequest.java + SearchSourceBuilder sourceBuilder = queryRequest.getSourceBuilder(); + if (sourceBuilder != null) { + String pitId = queryRequest.getPitId(); + String dslQuery; + + if (pitId != null) { + logger.info("Query request - PIT ID: {}", pitId); + // Configure PIT search request using the existing pitId + sourceBuilder.pointInTimeBuilder(new PointInTimeBuilder(pitId)); + sourceBuilder.timeout(queryRequest.getCursorKeepAlive()); + + // Check for search after + Object[] searchAfter = queryRequest.getSearchAfter(); + if (searchAfter != null) { + sourceBuilder.searchAfter(searchAfter); + } + + // Set sort field for search_after + if (sourceBuilder.sorts() == null) { + logger.info("Adding default sort fields for PIT"); + sourceBuilder.sort("_doc", SortOrder.ASC); + // Workaround to preserve sort location more exactly + // see https://github.com/opensearch-project/sql/pull/3061 + sourceBuilder.sort("_id", SortOrder.ASC); + } + } + + // Convert the final source builder to a string + dslQuery = sourceBuilder.toString(); + logger.info("Query request - Source builder: {}", dslQuery); + + // Write the DSL query to a file for the AWS interceptor to use + writeForAwsBody(dslQuery); + } else { + logger.info("Query request - Source builder: null"); + } + } + return request.search( + req -> { + try { + return client.search(req, RequestOptions.DEFAULT); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to perform search operation with request " + req, e); + } + }, + req -> { + try { + return client.scroll(req, RequestOptions.DEFAULT); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to perform scroll operation with request " + req, e); + } + }); + } + + /** + * Get the combination of the indices and the alias. + * + * @return the combination of the indices and the alias + */ + @Override + public List indices() { + logger.info("Getting indices"); + try { + GetIndexResponse indexResponse = + client.indices().get(new GetIndexRequest(), RequestOptions.DEFAULT); + final Stream aliasStream = + ImmutableList.copyOf(indexResponse.getAliases().values()).stream() + .flatMap(Collection::stream) + .map(AliasMetadata::alias); + return Stream.concat(Arrays.stream(indexResponse.getIndices()), aliasStream) + .collect(Collectors.toList()); + } catch (IOException e) { + throw new IllegalStateException("Failed to get indices", e); + } + } + + /** + * Get meta info of the cluster. + * + * @return meta info of the cluster. + */ + @Override + public Map meta() { + logger.info("Getting cluster meta info"); + try { + final ImmutableMap.Builder builder = new ImmutableMap.Builder<>(); + ClusterGetSettingsRequest request = new ClusterGetSettingsRequest(); + request.includeDefaults(true); + request.local(true); + final Settings defaultSettings = + client.cluster().getSettings(request, RequestOptions.DEFAULT).getDefaultSettings(); + builder.put(META_CLUSTER_NAME, defaultSettings.get("cluster.name", "opensearch")); + builder.put( + "plugins.sql.pagination.api", defaultSettings.get("plugins.sql.pagination.api", "true")); + return builder.build(); + } catch (IOException e) { + throw new IllegalStateException("Failed to get cluster meta info", e); + } + } + + @Override + public void cleanup(OpenSearchRequest request) { + logger.info("Cleaning up resources for request"); + if (request instanceof OpenSearchScrollRequest) { + request.clean( + scrollId -> { + try { + ClearScrollRequest clearRequest = new ClearScrollRequest(); + clearRequest.addScrollId(scrollId); + client.clearScroll(clearRequest, RequestOptions.DEFAULT); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to clean up resources for search request " + request, e); + } + }); + } else { + request.clean( + pitId -> { + DeletePitRequest deletePitRequest = new DeletePitRequest(pitId); + deletePit(deletePitRequest); + }); + } + } + + @Override + public void schedule(Runnable task) { + logger.info("Scheduling task"); + task.run(); + } + + @Override + public NodeClient getNodeClient() { + logger.info("Node Client is not supported"); + throw new UnsupportedOperationException("Unsupported method."); + } + + @Override + public String createPit(CreatePitRequest createPitRequest) { + logger.info("Creating PIT"); + + try { + // For the AWS interceptor to use + String bodyContent = getBodyContent(createPitRequest); + writeForAwsBody(bodyContent); + + CreatePitResponse createPitResponse = + client.createPit(createPitRequest, RequestOptions.DEFAULT); + String pitId = createPitResponse.getId(); + logger.info("PIT created successfully with ID: {}", pitId); + return pitId; + } catch (IOException e) { + throw new RuntimeException("Error occurred while creating PIT for new engine SQL query", e); + } + } + + @Override + public void deletePit(DeletePitRequest deletePitRequest) { + logger.info("Deleting PIT"); + + try { + // For the AWS interceptor to use + String bodyContent = getBodyContent(deletePitRequest); + writeForAwsBody(bodyContent); + + DeletePitResponse deletePitResponse = + client.deletePit(deletePitRequest, RequestOptions.DEFAULT); + + } catch (IOException e) { + throw new RuntimeException("Error occurred while deleting PIT", e); + } + } + + // Helper methods for AWS interceptor to sign its body + private String getBodyContent(ToXContent request) throws IOException { + XContentBuilder builder = XContentFactory.jsonBuilder(); + if (request instanceof DeletePitRequest) { + request.toXContent(builder, ToXContent.EMPTY_PARAMS); + } else if (request instanceof CreatePitRequest) { + builder.startObject(); + request.toXContent(builder, ToXContent.EMPTY_PARAMS); + builder.endObject(); + } + + String jsonBody = builder.toString(); + logger.info("===== {} Body Content =====", request.getClass().getSimpleName()); + logger.info("{}", jsonBody); + return jsonBody; + } + + private void writeForAwsBody(String content) { + File dslFile = new File("src/main/java/client/http5/aws/aws_body.json"); + try (FileWriter writer = new FileWriter(dslFile)) { + writer.write(content); + logger.info("Wrote DSL query to file: {}", dslFile.getAbsolutePath()); + } catch (IOException e) { + logger.error("Failed to write DSL query to file: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/client/http5/aws/AwsRequestSigningApacheV5Interceptor.java b/src/main/java/client/http5/aws/AwsRequestSigningApacheV5Interceptor.java new file mode 100644 index 0000000..b22fdd0 --- /dev/null +++ b/src/main/java/client/http5/aws/AwsRequestSigningApacheV5Interceptor.java @@ -0,0 +1,181 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package client.http5.aws; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.io.entity.BasicHttpEntity; +import org.apache.hc.core5.http.io.entity.BufferedHttpEntity; +import org.apache.hc.core5.http.message.BasicHeader; +import org.apache.hc.core5.http.message.BasicHttpRequest; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.auth.spi.signer.HttpSigner; +import software.amazon.awssdk.http.auth.spi.signer.SignedRequest; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; +import software.amazon.awssdk.regions.Region; + +// AWS Request Signing Interceptor by acm19 + +/** + * An {@link HttpRequestInterceptor} that signs requests for any AWS service running in a specific + * region using an AWS {@link HttpSigner} and {@link AwsCredentialsProvider}. + */ +public final class AwsRequestSigningApacheV5Interceptor implements HttpRequestInterceptor { + private static final Logger logger = + LoggerFactory.getLogger("AwsRequestSigningApacheV5Interceptor"); + private final RequestSigner signer; + + /** + * Creates an {@code AwsRequestSigningApacheInterceptor} with the ability to sign request for a + * specific service in a region and defined credentials. + * + * @param service service the client is connecting to + * @param signer signer implementation. + * @param awsCredentialsProvider source of AWS credentials for signing + * @param region signing region + */ + public AwsRequestSigningApacheV5Interceptor( + String service, + HttpSigner signer, + AwsCredentialsProvider awsCredentialsProvider, + Region region) { + this.signer = new RequestSigner(service, signer, awsCredentialsProvider, region); + } + + /** {@inheritDoc} */ + @Override + public void process(HttpRequest request, EntityDetails entityDetails, HttpContext context) + throws HttpException, IOException { + // copy Apache HttpRequest to AWS request + SdkHttpFullRequest.Builder requestBuilder = + SdkHttpFullRequest.builder() + .method(SdkHttpMethod.fromValue(request.getMethod())) + .uri(buildUri(request)); + + if (request instanceof ClassicHttpRequest) { + ClassicHttpRequest classicHttpRequest = (ClassicHttpRequest) request; + + if (classicHttpRequest.getEntity() != null) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + classicHttpRequest.getEntity().writeTo(outputStream); + if (!classicHttpRequest.getEntity().isRepeatable()) { + // copy back the entity, so it can be read again + BasicHttpEntity entity = + new BasicHttpEntity( + new ByteArrayInputStream(outputStream.toByteArray()), + ContentType.parse(entityDetails.getContentType())); + // wrap into repeatable entity to support retries + classicHttpRequest.setEntity(new BufferedHttpEntity(entity)); + } + requestBuilder.contentStreamProvider( + () -> new ByteArrayInputStream(outputStream.toByteArray())); + } + // RestClient is always BasicHttpRequest? + } else if (request instanceof BasicHttpRequest) { + // If it's POST/DELETE request, then manually adding body content to the body + // Because BasicHttpRequest does not have its body content attach to it + // Only its metadata + Set methods = Set.of("POST", "DELETE"); + if (methods.contains(request.getMethod().toUpperCase())) { + Path path = Paths.get("src/main/java/client/http5/aws/aws_body.json"); + String bodyContent = ""; + + if (Files.exists(path)) { + try (BufferedReader reader = Files.newBufferedReader(path)) { + bodyContent = reader.lines().collect(Collectors.joining("\n")); + } catch (IOException e) { + logger.error("Failed to read: " + e.getMessage()); + } + } else { + logger.warn("File does not exist at: " + path.toAbsolutePath()); + } + + // Only proceed if bodyContent is not empty + if (!bodyContent.isEmpty()) { + byte[] bodyBytes = bodyContent.getBytes(StandardCharsets.UTF_8); + logger.info("Byte added: " + bodyBytes.length); + logger.info("Body content signing: " + bodyContent); + requestBuilder.contentStreamProvider(() -> new ByteArrayInputStream(bodyBytes)); + } + } + } + + Map> headers = headerArrayToMap(request.getHeaders()); + // adds a hash of the request payload when signing + headers.put("x-amz-content-sha256", Collections.singletonList("required")); + requestBuilder.headers(headers); + SignedRequest signedRequest = signer.signRequest(requestBuilder.build()); + + // copy everything back + request.setHeaders(mapToHeaderArray(signedRequest.request().headers())); + } + + private static URI buildUri(HttpRequest request) throws IOException { + try { + return request.getUri(); + } catch (URISyntaxException ex) { + throw new IOException("Invalid URI", ex); + } + } + + private static Map> headerArrayToMap(Header[] headers) { + Map> headersMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (Header header : headers) { + if (!skipHeader(header)) { + headersMap.put( + header.getName(), + headersMap.getOrDefault( + header.getName(), new LinkedList<>(Collections.singletonList(header.getValue())))); + } + } + return headersMap; + } + + private static boolean skipHeader(Header header) { + return (HttpHeaders.CONTENT_LENGTH.equalsIgnoreCase(header.getName()) + && "0".equals(header.getValue())) // Strip Content-Length: 0 + || HttpHeaders.HOST.equalsIgnoreCase(header.getName()); // Host comes from endpoint + } + + private static Header[] mapToHeaderArray(Map> mapHeaders) { + Header[] headers = new Header[mapHeaders.size()]; + int i = 0; + for (Map.Entry> headerEntry : mapHeaders.entrySet()) { + for (String value : headerEntry.getValue()) { + headers[i++] = new BasicHeader(headerEntry.getKey(), value); + } + } + return headers; + } +} diff --git a/src/main/java/client/http5/aws/RequestSigner.java b/src/main/java/client/http5/aws/RequestSigner.java new file mode 100644 index 0000000..97fbad7 --- /dev/null +++ b/src/main/java/client/http5/aws/RequestSigner.java @@ -0,0 +1,100 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package client.http5.aws; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Objects; +import org.apache.http.HttpHost; +import org.apache.http.client.utils.URIBuilder; +import org.apache.http.protocol.HttpContext; +import org.apache.http.protocol.HttpCoreContext; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner; +import software.amazon.awssdk.http.auth.spi.signer.HttpSigner; +import software.amazon.awssdk.http.auth.spi.signer.SignedRequest; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; +import software.amazon.awssdk.regions.Region; + +// AWS Request Signing Interceptor by acm19 +class RequestSigner { + /** A service the client is connecting to. */ + private final String service; + + /** A signer implementation. */ + private final HttpSigner signer; + + /** The source of AWS credentials for signing. */ + private final AwsCredentialsProvider awsCredentialsProvider; + + /** The signing region. */ + private final Region region; + + /** + * @param service + * @param signer + * @param awsCredentialsProvider + * @param region + */ + RequestSigner( + String service, + HttpSigner signer, + AwsCredentialsProvider awsCredentialsProvider, + Region region) { + this.service = service; + this.signer = signer; + this.awsCredentialsProvider = awsCredentialsProvider; + this.region = Objects.requireNonNull(region); + } + + /** + * Signs the {@code request} using AWS + * Signature Version 4. + * + * @param request to be signed + * @return signed request + * @see AwsV4HttpSigner#sign + */ + SignedRequest signRequest(SdkHttpFullRequest request) { + SignedRequest signedRequest = + signer.sign( + r -> + r.identity(awsCredentialsProvider.resolveCredentials()) + .request(request) + .payload(request.contentStreamProvider().orElse(null)) + .putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, service) + .putProperty(AwsV4HttpSigner.REGION_NAME, region.id())); + + return signedRequest; + } + + /** + * Returns an {@link URI} from an HTTP context. + * + * @param context request context + * @param uri request line URI + * @return an {@link URI} from an HTTP context + * @throws IOException if the {@code uri} syntax is invalid + */ + static URI buildUri(HttpContext context, String uri) throws IOException { + try { + URIBuilder uriBuilder = new URIBuilder(uri); + + HttpHost host = (HttpHost) context.getAttribute(HttpCoreContext.HTTP_TARGET_HOST); + if (host != null) { + uriBuilder.setHost(host.getHostName()); + uriBuilder.setScheme(host.getSchemeName()); + uriBuilder.setPort(host.getPort()); + } + return uriBuilder.build(); + } catch (URISyntaxException ex) { + throw new IOException("Invalid URI", ex); + } + } +} diff --git a/src/main/java/query/CustomQueryManager.java b/src/main/java/query/CustomQueryManager.java new file mode 100644 index 0000000..6f3dcd0 --- /dev/null +++ b/src/main/java/query/CustomQueryManager.java @@ -0,0 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package query; + +import org.opensearch.sql.executor.QueryId; +import org.opensearch.sql.executor.QueryManager; +import org.opensearch.sql.executor.execution.AbstractPlan; +import org.opensearch.sql.opensearch.client.OpenSearchClient; + +// require QueryPlan, QueryID +public class CustomQueryManager implements QueryManager { + private final OpenSearchClient openSearchClient; + + public CustomQueryManager(OpenSearchClient openSearchClient) { + this.openSearchClient = openSearchClient; + } + + @Override + public QueryId submit(AbstractPlan queryPlan) { + QueryId queryId = queryPlan.getQueryId(); + queryPlan.execute(); + + return queryPlan.getQueryId(); + } + + @Override + public boolean cancel(QueryId queryId) { + return false; + } +} diff --git a/src/main/java/query/QueryExecution.java b/src/main/java/query/QueryExecution.java new file mode 100644 index 0000000..5945286 --- /dev/null +++ b/src/main/java/query/QueryExecution.java @@ -0,0 +1,58 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package query; + +import com.google.inject.Inject; +import org.opensearch.sql.ppl.PPLService; +import org.opensearch.sql.sql.SQLService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import query.execution.Execution; +import query.execution.PplExecution; +import query.execution.SqlExecution; + +/** Main class for executing queries using the factory and strategy patterns. */ +public class QueryExecution { + private static final Logger logger = LoggerFactory.getLogger(QueryExecution.class); + + private final PPLService pplService; + private final SQLService sqlService; + + @Inject + public QueryExecution(PPLService pplService, SQLService sqlService) { + this.pplService = pplService; + this.sqlService = sqlService; + } + + /** + * Execute a query using the appropriate execution implementation based on the query type. + * + * @param query The query to execute + * @param isPPL Whether the query is a PPL query + * @param isExplain Whether this is an explain query + * @param format The output format + * @return The formatted query result + */ + public String execute(String query, boolean isPPL, boolean isExplain, String format) { + logger.info("Received query: " + query); + logger.info("Query type: " + (isPPL ? "PPL" : "SQL")); + + try { + + Execution execution; + if (isPPL) { + execution = new PplExecution(pplService); + } else { + execution = new SqlExecution(sqlService); + } + + return execution.execute(query, isExplain, format); + } catch (Exception e) { + logger.error("Execution Error: ", e); + return e.toString(); + } + } +} diff --git a/src/main/java/query/execution/Execution.java b/src/main/java/query/execution/Execution.java new file mode 100644 index 0000000..1a6ce05 --- /dev/null +++ b/src/main/java/query/execution/Execution.java @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package query.execution; + +/** Interface for executing different types of queries. */ +public interface Execution { + + /** + * Execute a query and return the result as a formatted string. + * + * @param query The query to execute + * @param isExplain Whether this is an explain query + * @param format The output format + * @return The formatted query result + */ + String execute(String query, boolean isExplain, String format); +} diff --git a/src/main/java/query/execution/PplExecution.java b/src/main/java/query/execution/PplExecution.java new file mode 100644 index 0000000..012f151 --- /dev/null +++ b/src/main/java/query/execution/PplExecution.java @@ -0,0 +1,123 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package query.execution; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import org.json.JSONObject; +import org.opensearch.sql.common.response.ResponseListener; +import org.opensearch.sql.executor.ExecutionEngine.ExplainResponse; +import org.opensearch.sql.executor.ExecutionEngine.QueryResponse; +import org.opensearch.sql.ppl.PPLService; +import org.opensearch.sql.ppl.domain.PPLQueryRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import query.execution.formatter.ExecuteFormatter; +import query.execution.formatter.ExplainFormatter; + +/** Implementation for executing PPL queries. */ +public class PplExecution implements Execution { + private static final Logger logger = LoggerFactory.getLogger(PplExecution.class); + + private static final String EXPLAIN_PATH = "/_explain"; + private static final String PPL_PATH = "/_plugins/_ppl"; + + private final PPLService pplService; + + public PplExecution(PPLService pplService) { + this.pplService = pplService; + } + + @Override + public String execute(String query, boolean isExplain, String format) { + logger.info("Executing PPL query: " + query); + + try { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference executeRef = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + AtomicReference explainRef = new AtomicReference<>(); + + ResponseListener queryListener = + createQueryResponseListener(executeRef, errorRef, latch); + ResponseListener explainListener = + createExplainResponseListener(explainRef, errorRef, latch); + + // For explain queries, set the appropriate path + String path = isExplain ? EXPLAIN_PATH : PPL_PATH; + PPLQueryRequest pplRequest = new PPLQueryRequest(query, new JSONObject(), path, ""); + + if (isExplain) { + pplService.explain(pplRequest, explainListener); + } else { + pplService.execute(pplRequest, queryListener, explainListener); + } + + latch.await(); + + if (errorRef.get() != null) { + return errorRef.get().toString(); + } + + // Handle the response based on the query type + if (isExplain && explainRef.get() != null) { + return ExplainFormatter.format(explainRef.get(), format); + } else if (executeRef.get() != null) { + // For regular queries, use the query response + return ExecuteFormatter.format(executeRef.get(), format); + } else { + return "No results"; + } + } catch (Exception e) { + logger.error("PPL Execution Error: ", e); + return e.toString(); + } + } + + private ResponseListener createQueryResponseListener( + AtomicReference executeRef, + AtomicReference errorRef, + CountDownLatch latch) { + + return new ResponseListener<>() { + @Override + public void onResponse(QueryResponse response) { + logger.info("Execute Result: " + response); + executeRef.set(response); + latch.countDown(); + } + + @Override + public void onFailure(Exception e) { + logger.error("Execution Error: " + e); + errorRef.set(e); + latch.countDown(); + } + }; + } + + private ResponseListener createExplainResponseListener( + AtomicReference explainRef, + AtomicReference errorRef, + CountDownLatch latch) { + + return new ResponseListener<>() { + @Override + public void onResponse(ExplainResponse response) { + logger.info("Explain Result: " + response); + explainRef.set(response); + latch.countDown(); + } + + @Override + public void onFailure(Exception e) { + logger.error("Explain Error: " + e); + errorRef.set(e); + latch.countDown(); + } + }; + } +} diff --git a/src/main/java/query/execution/SqlExecution.java b/src/main/java/query/execution/SqlExecution.java new file mode 100644 index 0000000..fc42a55 --- /dev/null +++ b/src/main/java/query/execution/SqlExecution.java @@ -0,0 +1,127 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package query.execution; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import org.json.JSONObject; +import org.opensearch.sql.common.response.ResponseListener; +import org.opensearch.sql.executor.ExecutionEngine.ExplainResponse; +import org.opensearch.sql.executor.ExecutionEngine.QueryResponse; +import org.opensearch.sql.sql.SQLService; +import org.opensearch.sql.sql.domain.SQLQueryRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import query.execution.formatter.ExecuteFormatter; +import query.execution.formatter.ExplainFormatter; + +/** Implementation for executing SQL queries. */ +public class SqlExecution implements Execution { + private static final Logger logger = LoggerFactory.getLogger(SqlExecution.class); + + private static final String EXPLAIN_PATH = "/_explain"; + private static final String SQL_PATH = "/_plugins/_sql"; + + private final SQLService sqlService; + + public SqlExecution(SQLService sqlService) { + this.sqlService = sqlService; + } + + @Override + public String execute(String query, boolean isExplain, String format) { + logger.info("Executing SQL query: " + query); + + try { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference executeRef = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + AtomicReference explainRef = new AtomicReference<>(); + + ResponseListener queryListener = + createQueryResponseListener(executeRef, errorRef, latch); + ResponseListener explainListener = + createExplainResponseListener(explainRef, errorRef, latch); + + if (isExplain) { + // Remove "explain" prefix + String actualQuery = query.substring(7).trim(); + + String path = EXPLAIN_PATH; + SQLQueryRequest sqlRequest = new SQLQueryRequest(new JSONObject(), actualQuery, path, ""); + sqlService.execute(sqlRequest, queryListener, explainListener); + } else { + // Regular SQL query + String path = SQL_PATH; + SQLQueryRequest sqlRequest = new SQLQueryRequest(new JSONObject(), query, path, ""); + sqlService.execute(sqlRequest, queryListener, explainListener); + } + + latch.await(); + + if (errorRef.get() != null) { + return errorRef.get().toString(); + } + + // Handle the response based on the query type + if (isExplain && explainRef.get() != null) { + return ExplainFormatter.format(explainRef.get(), format); + } else if (executeRef.get() != null) { + // For regular queries, use the query response + return ExecuteFormatter.format(executeRef.get(), format); + } else { + return "No results"; + } + } catch (Exception e) { + logger.error("SQL Execution Error: ", e); + return e.toString(); + } + } + + private ResponseListener createQueryResponseListener( + AtomicReference executeRef, + AtomicReference errorRef, + CountDownLatch latch) { + + return new ResponseListener<>() { + @Override + public void onResponse(QueryResponse response) { + logger.info("Execute Result: " + response); + executeRef.set(response); + latch.countDown(); + } + + @Override + public void onFailure(Exception e) { + logger.error("Execution Error: " + e); + errorRef.set(e); + latch.countDown(); + } + }; + } + + private ResponseListener createExplainResponseListener( + AtomicReference explainRef, + AtomicReference errorRef, + CountDownLatch latch) { + + return new ResponseListener<>() { + @Override + public void onResponse(ExplainResponse response) { + logger.info("Explain Result: " + response); + explainRef.set(response); + latch.countDown(); + } + + @Override + public void onFailure(Exception e) { + logger.error("Explain Error: " + e); + errorRef.set(e); + latch.countDown(); + } + }; + } +} diff --git a/src/main/java/query/execution/formatter/ExecuteFormatter.java b/src/main/java/query/execution/formatter/ExecuteFormatter.java new file mode 100644 index 0000000..70bdebf --- /dev/null +++ b/src/main/java/query/execution/formatter/ExecuteFormatter.java @@ -0,0 +1,82 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package query.execution.formatter; + +import java.util.List; +import org.opensearch.sql.data.model.ExprValue; +import org.opensearch.sql.executor.ExecutionEngine.QueryResponse; +import org.opensearch.sql.executor.ExecutionEngine.Schema; +import org.opensearch.sql.protocol.response.QueryResult; +import org.opensearch.sql.protocol.response.format.CsvResponseFormatter; +import org.opensearch.sql.protocol.response.format.JdbcResponseFormatter; +import org.opensearch.sql.protocol.response.format.JsonResponseFormatter; +import org.opensearch.sql.protocol.response.format.RawResponseFormatter; +import org.opensearch.sql.protocol.response.format.ResponseFormatter; +import org.opensearch.sql.protocol.response.format.SimpleJsonResponseFormatter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Formatter for QueryResponse objects. */ +public class ExecuteFormatter { + private static final Logger logger = LoggerFactory.getLogger(ExecuteFormatter.class); + + private static final String FORMAT_CSV = "csv"; + private static final String FORMAT_JSON = "json"; + private static final String FORMAT_COMPACT_JSON = "compact_json"; + private static final String FORMAT_JDBC = "jdbc"; + private static final String FORMAT_RAW = "raw"; + private static final String FORMAT_TABLE = "table"; + + /** + * Format a QueryResponse object based on the specified format. + * + * @param response The QueryResponse to format + * @param format The output format + * @return The formatted response as a string + */ + public static String format(QueryResponse response, String format) { + try { + if (response == null || response.getResults() == null) { + return "No results"; + } + + Schema schema = response.getSchema(); + List results = (List) response.getResults(); + QueryResult queryResult = new QueryResult(schema, results); + + ResponseFormatter formatter = null; + + switch (format.toLowerCase()) { + case FORMAT_CSV: + formatter = new CsvResponseFormatter(); + break; + case FORMAT_JSON: + formatter = new SimpleJsonResponseFormatter(JsonResponseFormatter.Style.PRETTY); + break; + case FORMAT_COMPACT_JSON: + formatter = new SimpleJsonResponseFormatter(JsonResponseFormatter.Style.COMPACT); + break; + case FORMAT_JDBC: + formatter = new JdbcResponseFormatter(JsonResponseFormatter.Style.PRETTY); + break; + case FORMAT_RAW: + formatter = new RawResponseFormatter(); + break; + case FORMAT_TABLE: + formatter = new JdbcResponseFormatter(JsonResponseFormatter.Style.PRETTY); + break; + default: + formatter = new SimpleJsonResponseFormatter(JsonResponseFormatter.Style.PRETTY); + break; + } + + return formatter.format(queryResult); + } catch (Exception e) { + logger.error("Error formatting result: ", e); + return e.toString(); + } + } +} diff --git a/src/main/java/query/execution/formatter/ExplainFormatter.java b/src/main/java/query/execution/formatter/ExplainFormatter.java new file mode 100644 index 0000000..08702a0 --- /dev/null +++ b/src/main/java/query/execution/formatter/ExplainFormatter.java @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package query.execution.formatter; + +import org.opensearch.sql.executor.ExecutionEngine.ExplainResponse; +import org.opensearch.sql.protocol.response.format.JsonResponseFormatter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Formatter for ExplainResponse objects. same approach as in + * TransportPPLQueryAction.java/RestSQLQueryAction.java + */ +public class ExplainFormatter { + private static final Logger logger = LoggerFactory.getLogger(ExplainFormatter.class); + + /** + * Format an ExplainResponse object as JSON. + * + * @param response The ExplainResponse to format + * @param format The output format (currently only JSON is supported) + * @return The formatted response as a string + */ + public static String format(ExplainResponse response, String format) { + try { + String formatOutput = + new JsonResponseFormatter(JsonResponseFormatter.Style.PRETTY) { + @Override + protected Object buildJsonObject(ExplainResponse response) { + return response; + } + }.format(response); + return formatOutput; + + } catch (Exception e) { + logger.error("Error formatting explain result: ", e); + return e.toString(); + } + } +} diff --git a/src/main/python/opensearchsql_cli/__init__.py b/src/main/python/opensearchsql_cli/__init__.py new file mode 100644 index 0000000..3ecf5ac --- /dev/null +++ b/src/main/python/opensearchsql_cli/__init__.py @@ -0,0 +1,7 @@ +""" +OpenSearch SQL CLI Package + +A command-line interface for OpenSearch that supports SQL and PPL queries. +""" + +__version__ = "2.0.0" diff --git a/src/main/python/opensearchsql_cli/config/__init__.py b/src/main/python/opensearchsql_cli/config/__init__.py new file mode 100644 index 0000000..e6c8713 --- /dev/null +++ b/src/main/python/opensearchsql_cli/config/__init__.py @@ -0,0 +1,7 @@ +""" +OpenSearch SQL CLI Config Package + +This package provides access to configuration. +""" + +from .config import config_manager diff --git a/src/main/python/opensearchsql_cli/config/config.py b/src/main/python/opensearchsql_cli/config/config.py new file mode 100644 index 0000000..6125dc3 --- /dev/null +++ b/src/main/python/opensearchsql_cli/config/config.py @@ -0,0 +1,138 @@ +""" +Configuration Management + +This module provides functionality to manage configuration settings for OpenSearch CLI. +""" + +import os +import yaml +from rich.console import Console + +# Create a console instance for rich formatting +console = Console() + + +class Config: + """ + Class for managing configuration settings + """ + + def __init__(self): + """ + Initialize Config instance + """ + # Set up config file path + self.config_dir = os.path.dirname(os.path.abspath(__file__)) + self.config_file = os.path.join(self.config_dir, "config.yaml") + + # Initialize config as empty dictionary + self.config = {} + + # Load config file + self._load_config() + + def _load_config(self): + """ + Load configuration from file + """ + if os.path.exists(self.config_file): + try: + with open(self.config_file, "r") as f: + self.config = yaml.safe_load(f) or {} + + except Exception as e: + console.print( + f"[bold yellow]WARNING:[/bold yellow] [yellow]Could not read config file: {e}[/yellow]" + ) + + def get(self, section, key, default=None): + """ + Get a configuration value + + Args: + section: Configuration section + key: Configuration key + default: Default value if key doesn't exist or is empty + + Returns: + Value for the key or default if not found or empty + """ + try: + value = self.config.get(section, {}).get(key, default) + return value if value != "" else default + except (AttributeError, KeyError): + return default + + def get_boolean(self, section, key, default=False): + """ + Get a boolean configuration value + + Args: + section: Configuration section + key: Configuration key + default: Default value if key doesn't exist + + Returns: + Boolean value for the key or default if not found + """ + try: + value = self.config.get(section, {}).get(key, default) + if isinstance(value, bool): + return value + elif isinstance(value, str): + return value.lower() == "true" + else: + return bool(value) + except (AttributeError, KeyError, ValueError): + return default + + def set(self, section, key, value): + """ + Set a configuration value + + Args: + section: Configuration section + key: Configuration key + value: Value to set + + Returns: + bool: True if successful, False otherwise + """ + try: + if section not in self.config: + self.config[section] = {} + + self.config[section][key] = value + + with open(self.config_file, "w") as f: + yaml.dump(self.config, f, default_flow_style=False, sort_keys=False) + + return True + except Exception as e: + console.print( + f"[bold red]ERROR:[/bold red] [red]Could not write to config file: {e}[/red]" + ) + return False + + def display(self): + """ + Display current configuration + """ + console.print("[bold green]Current Configuration: \n[/bold green]") + + for section, items in self.config.items(): + console.print(f"[green][{section}][/green]") + for key, value in items.items(): + # Mask password + if section == "Connection" and key == "password" and value: + display_value = "********" + else: + display_value = value + + console.print(f" [green]{key}:[/green] {display_value}") + + console.print(f"[bold green]\nFile:[/bold green] {self.config_file}") + + +# Create a singleton instance +config_manager = Config() diff --git a/src/main/python/opensearchsql_cli/config/config.yaml b/src/main/python/opensearchsql_cli/config/config.yaml new file mode 100644 index 0000000..a13e4da --- /dev/null +++ b/src/main/python/opensearchsql_cli/config/config.yaml @@ -0,0 +1,110 @@ +# OpenSearch SQL CLI Configuration +# Edit this file to change default settings +# Must exit the CLI first if editing while CLI is running + +Main: + # Multi-line mode allows breaking up the statements into multiple lines. + # If this is set to True, then the end of the statements must have a semi-colon. + # If this is set to False then statements can't be split into multiple lines. + # End of line (return) is considered as the end of the statement. + multi_line: false + +Connection: + # OpenSearch connection settings + # For HTTP: localhost:9200 + # For HTTPS: https://localhost:9200 + # for AWS SigV4: URL + # If not providing port number after ":" + # By default: HTTP port is 9200 + # HTTPS port is 443 + endpoint: "" + + # Authentication credentials are necessary for HTTPS + # Must do "" as a string + # SECURITY WARNING: Storing passwords in this file poses a security risk as it is not encrypted. + # Consider using -u username:password instead for sensitive environments. + username: "" + password: "" + + # Set to true to skip certificate validation, -k flag + insecure: false + + # Set to true to use AWS SigV4 authentication + aws_auth: false + +Query: + # Default query language: PPL, SQL + # Default output format: Table, JSON, CSV + # Set to true for vertical table display mode + language: "" + format: "" + vertical: false + +SqlVersion: + # OpenSearch SQL plugin version, must do "" as a string + # version "version": use the Maven repository + # local "path/to/sql": use local jar files with absolute path + # remote "url": use git clone then use its local jar files + # branch_name "name": branch name to clone + # remote_output "path/to/save": path to save the repo to + version: "" + local: "" + remote: "" + branch_name: "" + remote_output: "" + +SqlSettings: + # Advanced settings for OpenSearch SQL plugin + # QUERY_SIZE_LIMIT: Maximum number of rows to return in a query result + # PPL Calcite results are limited by this number setting + # So, "HEAD" will not increase the limit + # Must modify this number to increase the result hits + # FIELD_TYPE_TOLERANCE: Whether to tolerate field type mismatches + # CALCITE_ENGINE_ENABLED: Whether to enable the Calcite SQL engine + # CALCITE_FALLBACK_ALLOWED: Whether to allow fallback to legacy engine if Calcite fails + # CALCITE_PUSHDOWN_ENABLED: Whether to enable pushdown optimization in Calcite + # CALCITE_PUSHDOWN_ROWCOUNT_ESTIMATION_FACTOR: Factor for row count estimation in pushdown + # SQL_CURSOR_KEEP_ALIVE: Time to keep cursor alive in minutes + QUERY_SIZE_LIMIT: 200 + FIELD_TYPE_TOLERANCE: true + CALCITE_ENGINE_ENABLED: true + CALCITE_FALLBACK_ALLOWED: true + CALCITE_PUSHDOWN_ENABLED: true + CALCITE_PUSHDOWN_ROWCOUNT_ESTIMATION_FACTOR: 1.0 + SQL_CURSOR_KEEP_ALIVE: 1 + +File: + # SQL library log location. + # Default: "logs/sql_library.log" + sql_log: "" + + # CLI command history file location. + # Default: "src/main/python/opensearchsql_cli/.cli_history" + history_file: "" + + # Saved query file location + # Default: "src/main/python/opensearchsql_cli/query/save_query/saved.txt" + saved_query: "" + +# Custom colors for various UI elements +Colors: + completion-menu.completion.current: "bg:#ffffff #000000" + completion-menu.completion: "bg:#008888 #ffffff" + completion-menu.meta.completion.current: "bg:#44aaaa #000000" + completion-menu.meta.completion: "bg:#448888 #ffffff" + completion-menu.multi-column-meta: "bg:#aaffff #000000" + scrollbar.arrow: "bg:#003333" + scrollbar: "bg:#00aaaa" + selected: "#ffffff bg:#6666aa" + search: "#ffffff bg:#4444aa" + search.current: "#ffffff bg:#44aa44" + bottom-toolbar: "bg:#222222 #aaaaaa" + bottom-toolbar.off: "bg:#222222 #888888" + bottom-toolbar.on: "bg:#222222 #ffffff" + search-toolbar: "noinherit bold" + search-toolbar.text: "nobold" + system-toolbar: "noinherit bold" + arg-toolbar: "noinherit bold" + arg-toolbar.text: "nobold" + bottom-toolbar.transaction.valid: "bg:#222222 #00ff5f bold" + bottom-toolbar.transaction.failed: "bg:#222222 #ff005f bold" diff --git a/src/main/python/opensearchsql_cli/interactive_shell.py b/src/main/python/opensearchsql_cli/interactive_shell.py new file mode 100644 index 0000000..699382f --- /dev/null +++ b/src/main/python/opensearchsql_cli/interactive_shell.py @@ -0,0 +1,397 @@ +""" +Interactive Shell + +Handles interactive shell functionality for OpenSearch SQL CLI. +""" + +import sys +import os +import traceback +from typing import Optional +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.shortcuts import PromptSession +from prompt_toolkit.lexers import PygmentsLexer +from prompt_toolkit.auto_suggest import AutoSuggestFromHistory +from prompt_toolkit.history import FileHistory +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.filters import Condition +from prompt_toolkit.styles import Style +from pygments.lexers.sql import SqlLexer + +from rich.console import Console +from rich.markup import escape +from .sql import sql_connection +from .query import ExecuteQuery +from .literals import Literals +from .config.config import config_manager +from .sql.sql_version import sql_version +from .sql.verify_cluster import VerifyCluster + +# Create a console instance for rich formatting +console = Console() + + +class InteractiveShell: + """ + Interactive Shell class for OpenSearch SQL CLI + """ + + COMMANDS = ["-l", "-f", "-v", "-s", "help", "-h", "--help", "exit", "quit", "q"] + LANGUAGE = ["ppl", "sql"] + FORMAT = ["table", "json", "csv"] + SAVE_OPTIONS = ["--save", "--load", "--remove", "--list"] + + def __init__(self, sql_connection, saved_queries): + """ + Initialize the Interactive Shell instance + + Args: + sql_connection: SQL connection instance + saved_queries: SavedQueries instance + """ + # Get history file path from config or use default + config_history_file = config_manager.get("File", "history_file", "") + if config_history_file and config_history_file.strip(): + self.histfile = config_history_file + else: + # Use default history file path + self.histfile = os.path.join(os.path.dirname(__file__), ".cli_history") + + self.history_length = float("inf") + + # Create history file if it doesn't exist + if not os.path.exists(self.histfile): + try: + # Create directory if it doesn't exist + os.makedirs(os.path.dirname(self.histfile), exist_ok=True) + open(self.histfile, "w").close() + except IOError: + console.print( + f"[bold yellow]WARNING:[/bold yellow] [yellow]Unable to create history file[/yellow] {self.histfile}" + ) + + # Store references to connection and saved queries + self.sql_connection = sql_connection + self.saved_queries = saved_queries + + # State variables + self.language_mode = "ppl" + self.is_ppl_mode = True + self.format = "table" + self.is_vertical = False + self.latest_query = None + + @staticmethod + def display_help_shell(): + """Display help while inside of interactive shell""" + console.print( + """[green]\nCommands:[/green][dim white] + - Execute query + -l - Change language: PPL, SQL + -f - Change format: JSON, Table, CSV + -v - Toggle vertical display mode + -s --save - Save the latest query with a name + -s --load - Load and execute the saved query + -s --remove - Remove a saved query by name + -s --list - List all saved query names + -h/help - Show this help + exit/quit/q - Exit interactive mode + [/dim white] +[green]NOTE:[/green] To use a different OpenSearch SQL plug-in version, restart the CLI with --version + """ + ) + + def auto_completer(self, language_mode): + """ + Get a WordCompleter for the current language mode + + Args: + language_mode: Current language mode (PPL or SQL) + + Returns: + WordCompleter: Completer for the current language mode + """ + # Use language mode directly for get_literals + lang = language_mode + + # Get literals based on the current language mode + literals = Literals.get_literals(lang) + keywords = [] + for keyword in literals.get("keywords", []): + keywords.append(keyword.upper()) + + functions = [] + for function in literals.get("functions", []): + functions.append(function.upper()) + + # Get indices from the connection + indices = [] + if self.sql_connection.client: + # Use the client from sql_connection + indices = VerifyCluster.get_indices(self.sql_connection.client) + + # Create a WordCompleter with all keywords, functions, indices, and commands + return WordCompleter( + keywords + + functions + + indices + + self.COMMANDS + + self.LANGUAGE + + self.FORMAT + + self.SAVE_OPTIONS, + ignore_case=True, + ) + + def execute_query(self, query): + """ + Execute a query + + Args: + query: Query string to execute + + Returns: + bool: True if successful, False otherwise + """ + try: + # Store the query for saved query + self.latest_query = query + + # Check if the query starts with "explain" + is_explain = query.strip().lower().startswith("explain") + + # Call ExecuteQuery directly with the appropriate language mode + success, result, formatted_result = ExecuteQuery.execute_query( + self.sql_connection, + query, + self.is_ppl_mode, + is_explain, + self.format, + self.is_vertical, + console.print, + ) + return success + except Exception as e: + console.print( + f"[bold red]ERROR:[/bold red] [red] Unable to execute [/red] {escape(str(e))}" + ) + traceback.print_exc() + return False + + def start(self, language=None, format=None): + """ + Start interactive query mode + + Args: + language: Language mode (PPL or SQL) + format: Output format (TABLE, JSON, CSV) + """ + + if language.lower() not in self.LANGUAGE: + console.print( + f"[bold red]Invalid Language:[/bold red] [red]{language.upper()}.[/red] [bold red]\nDefaulting to PPL.[/bold red]" + ) + language = "ppl" + + self.language_mode = language.lower() + self.is_ppl_mode = language.lower() == "ppl" + + # Validate format + if format.lower() not in self.FORMAT: + console.print( + f"[bold red]Invalid Format:[/bold red] [red]{format.upper()}.[/red] [bold red]\nDefaulting to TABLE.[/bold red]" + ) + self.format = "table" + else: + self.format = format.lower() + + # Track vertical display mode + self.is_vertical = config_manager.get_boolean("Query", "vertical", False) + + # Get multi-line mode setting from config + # In multi-line mode, statements must end with a semicolon + # If the input doesn't end with a semicolon and multi-line mode is enabled, + # the prompt will continue accepting input until a semicolon is entered + self.multi_line = config_manager.get_boolean("Main", "multi_line", True) + + # Create key bindings for custom behavior + kb = KeyBindings() + + # Define a condition to check if in multi-line mode + is_multiline = Condition(lambda: self.multi_line) + + # Add a key binding for Enter in multi-line mode + @kb.add("enter", filter=is_multiline) + def _(event): + # Get the current buffer text + buffer = event.current_buffer + text = buffer.text + + is_command = any(text.lower().startswith(cmd) for cmd in self.COMMANDS) + + # Check if the text ends with a semicolon or is a special command + if text.rstrip().endswith(";") or is_command: + # If it does, accept the input + buffer.validate_and_handle() + else: + # Otherwise, insert a newline + buffer.insert_text("\n") + + # If in PPL mode and multi-line is enabled, add a newline after the pipe | + @kb.add("|") + def _(event): + buffer = event.current_buffer + buffer.insert_text("|") + if self.is_ppl_mode and self.multi_line: + buffer.insert_text("\n") + + # Get color settings from config + colors_section = config_manager.config.get("Colors", {}) + style = Style.from_dict(colors_section) if colors_section else None + + # Create a PromptSession with auto-completion and syntax highlighting + session = PromptSession( + lexer=PygmentsLexer(SqlLexer), + completer=self.auto_completer(self.language_mode), + auto_suggest=AutoSuggestFromHistory(), + history=FileHistory(self.histfile), + multiline=self.multi_line, + prompt_continuation=lambda width, line_number, is_soft_wrap: ".... ", + key_bindings=kb, + style=style, + ) + + while True: + try: + # Get user input with auto-completion and syntax highlighting + user_input = session.prompt(f"\n{self.language_mode.upper()}> ") + + if not user_input: + continue + + # Process special commands + user_cmd = user_input.lower() + + # Handle exit commands + if user_cmd in ["exit", "quit", "q"]: + console.print("[bold green]\nSee you next search!\n[/bold green]") + break + + # Handle help command + if user_cmd in ["help", "-h", "--help"]: + console.print( + f"[green]\nSQL:[/green] [dim white]v{sql_version.version}[/dim white]" + ) + console.print( + f"[green]Language:[/green] [dim white]{self.language_mode.upper()}[/dim white]" + ) + console.print( + f"[green]Format:[/green] [dim white]{self.format.upper()}[/dim white]" + ) + console.print( + f"[green]Multi-line Mode:[/green] {'[green]ON[/green]' if self.multi_line else '[red]OFF[/red]'}" + ) + self.display_help_shell() + continue + + # Handle command-line style arguments + # Language change + if user_cmd.startswith("-l"): + language_type = user_input[3:].strip().lower() + if language_type in self.LANGUAGE: + self.language_mode = language_type + self.is_ppl_mode = language_type == "ppl" + console.print( + f"[green]\nLanguage changed to {self.language_mode.upper()}[/green]" + ) + # Update auto-completion for the new language + session.completer = self.auto_completer(self.language_mode) + else: + console.print( + f"[red]\nInvalid language mode: {language_type.upper()}.[/red]" + ) + continue + + # Format change + if user_cmd.startswith("-f"): + format_type = user_input[3:].strip().lower() + if format_type in self.FORMAT: + self.format = format_type + console.print( + f"[green]\nOutput format changed to {self.format.upper()}[/green]" + ) + else: + console.print( + f"[red]\nInvalid format: {format_type.upper()}.[/red]" + ) + continue + + # Toggle vertical table display + if user_cmd == "-v": + self.is_vertical = not self.is_vertical + console.print( + f"[green]\nTable Vertical:[/green] {'[green]ON[/green]' if self.is_vertical else '[red]OFF[/red]'}" + ) + continue + + # Saved query + if user_cmd.startswith("-s"): + # Parse saved queries commands + args = user_input.split() + if len(args) >= 2: + if args[1] == "--save" and len(args) >= 3: + # Save the latest query + name = args[2] + self.saved_queries.saving_query( + name, + ( + self.latest_query + if hasattr(self, "latest_query") + else None + ), + self.language_mode, + ) + elif args[1] == "--load" and len(args) >= 3: + # Load and execute a saved query + name = args[2] + success, query, result, language = ( + self.saved_queries.loading_query( + name, + self.sql_connection, + self.format, + self.is_vertical, + ) + ) + + if success: + # Store the latest query for saving + self.latest_query = query + elif args[1] == "--remove" and len(args) >= 3: + # Remove a saved query + name = args[2] + self.saved_queries.removing_query(name) + elif args[1] == "--list": + # List all saved query names + self.saved_queries.list_saved_queries() + else: + console.print( + "[red]\nInvalid -s command. \nUse --options [/red]" + ) + else: + console.print( + "[red]\nMissing option for -s command. Use --save, --load, --remove, or --list.[/red]" + ) + continue + + # If not a special command, treat as a query + self.execute_query(user_input) + + except KeyboardInterrupt: + console.print("[bold green]\nSee you next search!\n[/bold green]") + break + except EOFError: + console.print("[bold green]\nSee you next search!\n[/bold green]") + break + + +# Create a global instance +interactive_shell = None diff --git a/src/main/python/opensearchsql_cli/literals/__init__.py b/src/main/python/opensearchsql_cli/literals/__init__.py new file mode 100644 index 0000000..84085b1 --- /dev/null +++ b/src/main/python/opensearchsql_cli/literals/__init__.py @@ -0,0 +1,7 @@ +""" +OpenSearch SQL CLI Literals Package + +This package provides access to OpenSearch SQL and PPL keywords and functions for auto-completion. +""" + +from .opensearch_literals import Literals diff --git a/src/main/python/opensearchsql_cli/literals/opensearch_literals.py b/src/main/python/opensearchsql_cli/literals/opensearch_literals.py new file mode 100644 index 0000000..8701e4c --- /dev/null +++ b/src/main/python/opensearchsql_cli/literals/opensearch_literals.py @@ -0,0 +1,67 @@ +""" +OpenSearch Literals + +This module provides access to OpenSearch SQL and PPL keywords and functions for auto-completion. +""" + +import os +import json +from rich.text import Text + + +class Literals: + """Class for handling OpenSearch literals (keywords and functions).""" + + @staticmethod + def get_literals(language="ppl"): + """Parse literals JSON file based on language (sql or ppl). + + Args: + language: The query language, either 'sql' or 'ppl' (default: 'ppl') + + Returns: + A dict that is parsed from the corresponding literals JSON file + """ + package_root = os.path.dirname(__file__) + + LANGUAGE_LITERALS = { + "ppl": "opensearch_literals_ppl.json", + "sql": "opensearch_literals_sql.json", + } + literal_file = os.path.join( + package_root, + LANGUAGE_LITERALS.get(language.lower(), "opensearch_literals_sql.json"), + ) + + with open(literal_file) as f: + literals = json.load(f) + return literals + + @staticmethod + def colorize_keywords(text, literals): + """Colorize keywords and functions in the text. + + Args: + text: The text to colorize + literals: The literals dict containing keywords and functions + + Returns: + The text with keywords and functions colorized + """ + # Get keywords and functions + keywords = literals.get("keywords", []) + functions = literals.get("functions", []) + + # Convert text to lowercase for case-insensitive matching + text_lower = text.lower() + + # Check if the text matches any keyword or function + for keyword in keywords: + if text_lower == keyword.lower(): + return Text(text, style="bold green") + + for function in functions: + if text_lower == function.lower(): + return Text(text, style="green") + + return text diff --git a/src/main/python/opensearchsql_cli/literals/opensearch_literals_ppl.json b/src/main/python/opensearchsql_cli/literals/opensearch_literals_ppl.json new file mode 100644 index 0000000..86cf78e --- /dev/null +++ b/src/main/python/opensearchsql_cli/literals/opensearch_literals_ppl.json @@ -0,0 +1,109 @@ +{ + "keywords": [ + "search", + "source", + "where", + "fields", + "rename", + "dedup", + "stats", + "eventstats", + "sort", + "eval", + "head", + "top", + "rare", + "parse", + "regex", + "punct", + "grok", + "pattern", + "patterns", + "new_field", + "kmeans", + "ad", + "ml", + "fillnull", + "flatten", + "trendline", + "appendcol", + "expand", + "from", + "by", + "as", + "using", + "with", + "join", + "on", + "inner", + "outer", + "full", + "semi", + "anti", + "cross", + "left", + "right", + "like", + "and", + "or", + "not", + "xor", + "in", + "exists", + "between", + "is", + "null", + "true", + "false", + "asc", + "desc", + "span", + "case", + "else", + "if", + "describe", + "show", + "explain", + "index", + "datasources" + ], + "functions": [ + "count", + "sum", + "avg", + "min", + "max", + "var_samp", + "var_pop", + "stddev_samp", + "stddev_pop", + "percentile", + "concat", + "substring", + "trim", + "ltrim", + "rtrim", + "upper", + "lower", + "replace", + "abs", + "ceil", + "floor", + "round", + "sqrt", + "log", + "log10", + "exp", + "pow", + "mod", + "date_format", + "now", + "timestamp", + "adddate", + "case", + "if", + "ifnull", + "isnull", + "coalesce" + ] +} diff --git a/src/opensearch_sql_cli/opensearch_literals/opensearch_literals.json b/src/main/python/opensearchsql_cli/literals/opensearch_literals_sql.json similarity index 97% rename from src/opensearch_sql_cli/opensearch_literals/opensearch_literals.json rename to src/main/python/opensearchsql_cli/literals/opensearch_literals_sql.json index 59f4f45..7c022e0 100644 --- a/src/opensearch_sql_cli/opensearch_literals/opensearch_literals.json +++ b/src/main/python/opensearchsql_cli/literals/opensearch_literals_sql.json @@ -12,6 +12,7 @@ "DELETE", "DESC", "DESCRIBE", + "EXPLAIN", "FROM", "FULL", "GROUP BY", diff --git a/src/main/python/opensearchsql_cli/main.py b/src/main/python/opensearchsql_cli/main.py new file mode 100644 index 0000000..98f2cb1 --- /dev/null +++ b/src/main/python/opensearchsql_cli/main.py @@ -0,0 +1,368 @@ +""" +CLI Commands and Interactive Mode + +Handles command-line interface, interactive mode, and user interactions. +""" + +import typer +import sys +import atexit +import signal +import pyfiglet + +from rich.console import Console +from rich.status import Status +from .sql import sql_connection +from .query import SavedQueries +from .sql.sql_library_manager import sql_library_manager +from .sql.sql_version import sql_version +from .config.config import config_manager +from .interactive_shell import InteractiveShell + +# Create a console instance for rich formatting +console = Console() + + +class OpenSearchSqlCli: + """ + OpenSearch SQL CLI class for managing command-line interface and interactive mode + """ + + def __init__(self): + """ + Initialize the OpenSearch SQL CLI instance + """ + # Create a connection instance + self.sql_connection = sql_connection + + # Create SavedQueries instance + self.saved_queries = SavedQueries() + + # Create InteractiveShell instance + self.shell = InteractiveShell(self.sql_connection, self.saved_queries) + + self.app = typer.Typer( + help="OpenSearch SQL CLI - Command Line Interface for OpenSearch SQL Plug-in" + ) + + # Register commands + self.register_commands() + + # Register cleanup function + atexit.register(self.cleanup_on_exit) + + def cleanup_on_exit(self): + """Cleanup function called when CLI exits""" + # Stop the SQL Library server + if sql_library_manager.started: + sql_library_manager.stop() + + def register_commands(self): + """Register commands with the Typer app""" + + @self.app.callback(invoke_without_command=True) + def main( + ctx: typer.Context, + endpoint: str = typer.Option( + None, + "--endpoint", + "-e", + help="OpenSearch endpoint: localhost:9200, https://localhost:9200", + ), + username_password: str = typer.Option( + None, + "--user", + "-u", + help="Username and password in format username:password", + ), + insecure: bool = typer.Option( + False, + "--insecure", + "-k", + is_flag=True, + help="Ignore SSL certificate validation", + ), + aws_auth: str = typer.Option( + None, + "--aws-auth", + help="Use AWS SigV4 authentication for the provided URL", + ), + language: str = typer.Option( + None, + "--language", + "-l", + help="Set language mode: PPL, SQL", + autocompletion=lambda ctx, incomplete: [ + "PPL", + "SQL", + ], + ), + format: str = typer.Option( + None, + "--format", + "-f", + help="Set output format: Table, JSON, CSV", + autocompletion=lambda ctx, incomplete: [ + "TABLE", + "JSON", + "CSV", + ], + ), + version: str = typer.Option( + None, + "--version", + help="Set OpenSearch SQL plug-in version: 3.1, 2.19", + autocompletion=lambda ctx, incomplete: [ + "3.1", + "2.19", + ], + ), + local_dir: str = typer.Option( + None, + "--local", + help="Use a local directory containing the SQL plugin JAR", + ), + remote: str = typer.Option( + None, + "--remote", + help='Clone from a git repository: --remote "https://github.com/opensearch-project/sql.git"', + ), + branch: str = typer.Option( + None, + "--branch", + "-b", + help='Branch name to clone (defaults to config value or "main")', + ), + remote_output: str = typer.Option( + None, + "--output", + "-o", + help="Custom output directory for cloned repository (used with --remote)", + ), + rebuild: bool = typer.Option( + False, + "--rebuild", + help="Rebuild the JAR file to update to latest timestamp version", + ), + query: str = typer.Option( + None, + "--query", + "-q", + help="Execute a query (non-interactive mode)", + ), + config: bool = typer.Option( + False, + "--config", + "-c", + help="Display current configuration settings", + ), + ): + """ + OpenSearch SQL CLI - Command Line Interface for OpenSearch SQL Plug-in + """ + + # Display config if requested + if config: + config_manager.display() + return + + print("") + # Version selection logic with priority + # Command arg has priority over config then default + version_to_use = version + local_dir_to_use = local_dir + remote_to_use = remote + + # If command line options not provided, try config file + if not (version_to_use or local_dir_to_use or remote_to_use): + version_to_use = config_manager.get("SqlVersion", "version", "") + local_dir_to_use = config_manager.get("SqlVersion", "local", "") + remote_to_use = config_manager.get("SqlVersion", "remote", "") + + # Process based on which option is available + # --version > --local > --remote priority + if version_to_use: + # Version provided + success = sql_version.set_version( + version=version_to_use, rebuild=rebuild + ) + if not success: + return + elif local_dir_to_use: + # Local directory provided + success = sql_version.set_local_version( + local_dir_to_use, rebuild=rebuild + ) + if not success: + return + elif remote_to_use: + # Remote git info provided + git_url = remote_to_use + + # Get branch name from config if not provided via command line + if branch is None: + branch_name = config_manager.get( + "SqlVersion", "branch_name", "main" + ) + else: + branch_name = branch + + # Get remote_output from config if not provided via command line + if remote_output is None: + remote_output = config_manager.get( + "SqlVersion", "remote_output", "" + ) + + success = sql_version.set_remote_version( + branch_name, + git_url, + rebuild=rebuild, + remote_output=remote_output, + ) + if not success: + return + else: + # Use the default latest version if no options provided + success = sql_version.set_version(sql_version.version, rebuild) + if not success: + return + + # Get defaults from config if not provided + if language is None: + language = config_manager.get("Query", "language", "ppl") + if format is None: + format = config_manager.get("Query", "format", "table") + + # Initialize OpenSearch connection + if endpoint is None or endpoint == "": + host_port = config_manager.get("Connection", "endpoint", "localhost") + else: + host_port = endpoint + + # Use config value for insecure if not provided + if insecure is None: + ignore_ssl = config_manager.get_boolean("Connection", "insecure", False) + else: + ignore_ssl = insecure + + # Use config values for username and password if not provided + if username_password is None: + username = config_manager.get("Connection", "username", "") + password = config_manager.get("Connection", "password", "") + if username and password: + username_password = f"{username}:{password}" + + # If aws_auth is specified as a command-line argument, use it as the endpoint + if aws_auth: + host_port = aws_auth + aws_auth = True + elif config_manager.get_boolean("Connection", "aws_auth", False): + aws_auth = True + else: + aws_auth = False + + with console.status("Verifying OpenSearch connection...", spinner="dots"): + if not self.sql_connection.verify_opensearch_connection( + host_port, username_password, ignore_ssl, aws_auth + ): + if ( + hasattr(self.sql_connection, "error_message") + and self.sql_connection.error_message + ): + console.print( + f"[bold red]ERROR:[/bold red] [red]{self.sql_connection.error_message}[/red]\n" + ) + return + + with console.status("Initializing SQL Library...", spinner="dots"): + if not self.sql_connection.initialize_sql_library( + host_port, username_password, ignore_ssl, aws_auth + ): + if ( + hasattr(self.sql_connection, "error_message") + and self.sql_connection.error_message + ): + console.print( + f"[bold red]ERROR:[/bold red] [red]{self.sql_connection.error_message}[/red]\n" + ) + return + + # print Banner + banner = pyfiglet.figlet_format("OpenSearch", font="slant") + print(banner) + + # Display OpenSearch connection information + console.print( + f"[green]OpenSearch:[/green] [dim white]v{self.sql_connection.cluster_version}[/dim white]" + ) + console.print(f"[green]Endpoint:[/green] {self.sql_connection.url}") + if self.sql_connection.username: + if aws_auth: + # For AWS connections, display the region + console.print( + f"[green]Region:[/green] [dim white]{self.sql_connection.username}[/dim white]" + ) + else: + # For regular connections, display the username + console.print( + f"[green]User:[/green] [dim white]{self.sql_connection.username}[/dim white]" + ) + console.print( + f"[green]SQL:[/green] [dim white]v{sql_version.version}[/dim white]" + ) + console.print( + f"[green]Language:[/green] [dim white]{language.upper()}[/dim white]" + ) + console.print( + f"[green]Format:[/green] [dim white]{format.upper()}[/dim white]" + ) + + # Execute single query non-interactive mode + if query: + # Initialize the interactive shell + self.shell.language_mode = language.lower() + self.shell.is_ppl_mode = language.lower() == "ppl" + self.shell.format = format.lower() + self.shell.execute_query(query) + print("") + return + + # Start interactive shell + self.shell.start(language, format) + + +def main(): + """Main entry point""" + try: + # Set up signal handlers for graceful shutdown + def signal_handler(sig, frame): + print("\nReceived interrupt signal. Shutting down...") + # Stop the SQL Library server + if sql_library_manager.started: + sql_library_manager.stop() + sys.exit(0) + + # Register signal handlers for graceful shutdown + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Create CLI instance + cli = OpenSearchSqlCli() + + # Run the Typer app + return cli.app() + except Exception as e: + print(f"Error starting OpenSearch SQL CLI: {e}") + import traceback + + traceback.print_exc() + + # Make sure to stop the SQL Library process on error + if sql_library_manager.started: + sql_library_manager.stop() + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/main/python/opensearchsql_cli/query/__init__.py b/src/main/python/opensearchsql_cli/query/__init__.py new file mode 100644 index 0000000..ab0ff69 --- /dev/null +++ b/src/main/python/opensearchsql_cli/query/__init__.py @@ -0,0 +1,10 @@ +""" +Query Package + +This package provides functionality for CLI operations, query execution, and result formatting. +""" + +from .execute_query import ExecuteQuery +from .query_results import QueryResults +from .saved_queries import SavedQueries +from .explain_results import ExplainResults diff --git a/src/main/python/opensearchsql_cli/query/execute_query.py b/src/main/python/opensearchsql_cli/query/execute_query.py new file mode 100644 index 0000000..b959e99 --- /dev/null +++ b/src/main/python/opensearchsql_cli/query/execute_query.py @@ -0,0 +1,115 @@ +""" +Query Execution + +This module provides functionality for executing queries and formatting results. +""" + +from rich.console import Console +from rich.status import Status +from rich.markup import escape +from .query_results import QueryResults +from .explain_results import ExplainResults + +# Create a console instance for rich formatting +console = Console() + + +class ExecuteQuery: + """ + Class for executing queries and formatting results + """ + + @staticmethod + def execute_query( + connection, + query, + is_ppl_mode, + is_explain, + format, + is_vertical=False, + print_function=None, + ): + """ + Execute a query and format the result + + Args: + connection: Connection object to execute the query + query: Query string to execute + is_ppl_mode: Whether to use PPL mode (True) or SQL mode (False) + is_explain: Whether this is Explain or Execute query + format: Output format (json, table, csv) + is_vertical: Whether to display results in vertical format + print_function: Function to use for printing (default: console.print) + + Returns: + tuple: (success, result, formatted_result) + """ + if print_function is None: + print_function = console.print + + console.print(f"\nExecuting: [yellow]{query}[/yellow]\n") + + # Execute the query + with console.status("Executing the query...", spinner="dots"): + result = connection.query_executor(query, is_ppl_mode, is_explain, format) + + # Errors handling + # print_function(f"Before format: \n" + escape(result) + "\n") + if "Exception" in result: + if "index_not_found_exception" in result: + print_function("[bold red]Index does not exist[/bold red]") + elif "SyntaxCheckException" in result: + error_parts = result.split("SyntaxCheckException:", 1) + print_function( + f"[bold red]Syntax Error: [/bold red][red]{escape(error_parts[1].strip())}[/red]\n" + ) + elif "SemanticCheckException" in result: + error_parts = result.split("SemanticCheckException:", 1) + print_function( + f"[bold red]Semantic Error: [/bold red][red]{escape(error_parts[1].strip())}[/red]\n" + ) + elif '"statement" is null' in result or "NullPointerException" in result: + print_function( + "[bold red]Error: [/bold red][red]Could not parse the query. Please check the syntax and try again.[/red]" + ) + else: + print_function(f"[bold red]Error:[/bold red] {escape(str(result))}") + return False, result, result + + print_function(f"Result:\n") + with console.status("Formatting results...", spinner="dots"): + # For explain query + if is_explain: + if "calcite" in result: + explain_result = ExplainResults.explain_calcite(result) + print_function(explain_result) + return True, result, result + elif "root" in result: + explain_result = ExplainResults.explain_legacy(result) + print_function(explain_result) + return True, result, result + else: + print_function(f"{result}") + # For execute query + else: + if format.lower() == "table": + table_data = QueryResults.table_format(result, is_vertical) + if "error" in table_data and table_data["error"]: + print_function( + f"[bold red]Error:[/bold red] {table_data['message']}" + ) + return False, result, table_data["result"] + else: + # Display table + QueryResults.display_table_result(table_data, print_function) + return True, result, str(table_data) + elif format.lower() == "csv": + # return the result with white color + # because Rich automatically pretty-printing + print_function(f"[white]{escape(result)}[/white]") + return True, result, result + else: + # For other formats, use the result directly + # Right now, only JSON + print_function(f"{escape(result)}") + return True, result, result diff --git a/src/main/python/opensearchsql_cli/query/explain_results.py b/src/main/python/opensearchsql_cli/query/explain_results.py new file mode 100644 index 0000000..93abe82 --- /dev/null +++ b/src/main/python/opensearchsql_cli/query/explain_results.py @@ -0,0 +1,398 @@ +import json +import re + + +class ExplainResults: + """ + Class for formatting explain results + """ + + def explain_legacy(result): + """ + Format legacy explain + + Args: + result: legacy explain result + + Returns: + string: format_result + """ + + data = json.loads(result) + + fields_str = data["root"]["description"]["fields"] + fields_list = [ + f.strip() for f in fields_str.strip("[]").split(",") if f.strip() + ] + data["root"]["description"]["fields"] = fields_list + + # Extract the request string + request_str = data["root"]["children"][0]["description"]["request"] + match = re.match(r"(\w+)\((.*)\)", request_str) + + # Parse the request string into components + if match: + request_type = match.group(1) # "OpenSearchQueryRequest" + inner = match.group(2) # inside the parentheses + + # Split by comma + space only on top level + parts = re.split(r", (?=\w+=)", inner) + + request_obj = {} + + for part in parts: + key, val = part.split("=", 1) + + # Parse JSON for sourceBuilder + if key == "sourceBuilder": + val_obj = json.loads(val) + else: + val_obj = val + request_obj[key] = val_obj + + # Update the request structure + data["root"]["children"][0]["description"]["request"] = { + request_type: request_obj + } + + explain_result = json.dumps(data, indent=2) + return explain_result + + def explain_calcite(result): + """ + Format calcite explain + + Args: + result: calcite explain result + + Returns: + string: format_result + """ + + data = json.loads(result) + + # Process logical plan dynamically + logical_str = data["calcite"]["logical"] + logical_structured = parse_plan_tree(logical_str) + + # Process physical plan dynamically + physical_str = data["calcite"]["physical"] + physical_structured = parse_plan_tree(physical_str) + + # Update the whole data structure + data["calcite"]["logical"] = logical_structured + data["calcite"]["physical"] = physical_structured + + explain_result = json.dumps(data, indent=2) + return explain_result + + +def parse_plan_tree(plan_str): + """ + Dynamically parse a plan tree string into structured format. + + Args: + plan_str (str): The plan string with operators and their parameters + + Returns: + dict: Parsed plan structure + """ + if not plan_str or not plan_str.strip(): + return {} + + result = {} + lines = plan_str.strip().split("\n") + + for line in lines: + line = line.strip() + if not line: + continue + + # Extract operator name and parameters + operator_match = re.match(r"(\w+)\((.*)\)$", line) + if operator_match: + operator_name = operator_match.group(1) + params_str = operator_match.group(2) + + # Parse dynamically + params = parse_parameters(params_str) + result[operator_name] = params + + return result + + +def parse_respecting_nesting(content, target_char, split_mode=True): + """ + Parse content while respecting nested brackets, parentheses, braces, and quotes. + Can either split on target_char or find first occurrence of target_char. + + Args: + content (str): Content to parse + target_char (str): Character to split on or find + split_mode (bool): If True, split on target_char. If False, find first occurrence. + + Returns: + list or int: List of split elements if split_mode=True, position if split_mode=False (-1 if not found) + """ + if not content.strip(): + return [] if split_mode else -1 + + elements = [] if split_mode else None + current_element = "" if split_mode else None + bracket_depth = 0 + paren_depth = 0 + brace_depth = 0 + in_quotes = False + quote_char = None + + i = 0 + while i < len(content): + char = content[i] + + if char in ['"', "'"] and not in_quotes: + in_quotes = True + quote_char = char + elif char == quote_char and in_quotes: + in_quotes = False + quote_char = None + elif not in_quotes: + if char == "[": + bracket_depth += 1 + elif char == "]": + bracket_depth -= 1 + elif char == "(": + paren_depth += 1 + elif char == ")": + paren_depth -= 1 + elif char == "{": + brace_depth += 1 + elif char == "}": + brace_depth -= 1 + elif ( + char == target_char + and bracket_depth == 0 + and paren_depth == 0 + and brace_depth == 0 + ): + if split_mode: + # A separator + if current_element.strip(): + elements.append(current_element.strip()) + current_element = "" + i += 1 + continue + else: + # Found the first unescaped character + return i + + if split_mode: + current_element += char + i += 1 + + if split_mode: + # Last element + if current_element.strip(): + elements.append(current_element.strip()) + return elements + else: + # Character not found + return -1 + + +def split_respecting_nesting(content, separator): + """ + Split content on separator while respecting nested structures. + + Args: + content (str): Content to split + separator (str): Character to split on + + Returns: + list: List of split elements + + Example: + Input: "table=[[OpenSearch, employees]], PushDownContext=[[PROJECT->[name]]]" + Output: ["table=[[OpenSearch, employees]]", "PushDownContext=[[PROJECT->[name]]]"] + """ + return parse_respecting_nesting(content, separator, split_mode=True) + + +def find_first_unescaped_char(content, target_char): + """ + Find the first occurrence of target_char that's not inside nested structures. + + Args: + content (str): Content to search in + target_char (str): Character to find + + Returns: + int: Position of first unescaped character, or -1 if not found + + Example: + Input: "table=[[OpenSearch, employees]]", "=" + Output: 5 + """ + return parse_respecting_nesting(content, target_char, split_mode=False) + + +def parse_parameters(params_str): + """ + Dynamically parse parameter string into structured format. + + Args: + params_str (str): Parameter string + + Returns: + dict: Parsed parameters + + Example: + Input: "group=[{}], sum(aa)=[SUM($0)]" + Output: { + "group": [{}], + "sum(aa)": "[SUM($0)]" + } + """ + if not params_str or not params_str.strip(): + return {} + + params = {} + + # Use the common splitting function + param_parts = split_respecting_nesting(params_str, ",") + + for param_part in param_parts: + param_part = param_part.strip() + if not param_part: + continue + + # Find the first unescaped '=' sign + eq_pos = find_first_unescaped_char(param_part, "=") + + if eq_pos > 0: + key = param_part[:eq_pos].strip() + value = param_part[eq_pos + 1 :].strip() + + # Special handling for JSON values + if value.startswith("{") and value.endswith("}"): + try: + # Try to parse as JSON + json_obj = json.loads(value) + params[key] = json_obj + except json.JSONDecodeError: + # If fails then treat as regular parameter value + params[key] = parse_parameter_value(value) + else: + params[key] = parse_parameter_value(value) + else: + # No valid '=' found, treat as boolean parameter + params[param_part] = True + + return params + + +def parse_arrow_structure(content): + """ + Parse PROJECT->[field1, field2, ...] structure. + + Args: + content (str): Content to parse + + Returns: + dict: Parsed structure + + Example: + Input: "PROJECT->[field1, field2, ...]" + Output: {"PROJECT->": ["field1", "field2", ...]} + """ + arrow_pos = content.find("->") + if arrow_pos <= 0: + return content + + key = content[:arrow_pos].strip() + value_part = content[arrow_pos + 2 :].strip() + + if value_part.startswith("[") and value_part.endswith("]"): + # Parse the array part + array_content = value_part[1:-1].strip() + if array_content: + fields = split_respecting_nesting(array_content, ",") + return {key + "->": [field.strip() for field in fields]} + else: + return {key + "->": []} + else: + return {key + "->": value_part} + + +def parse_parameter_value(value_str): + """ + Parse a parameter value, handling arrays, nested structures, function calls, etc. + + Args: + value_str (str): Value string to parse + + Returns: + Parsed value (list, dict, or string) + """ + value_str = value_str.strip() + + # Handle arrays + if value_str.startswith("[") and value_str.endswith("]"): + inner_content = value_str[1:-1].strip() + + if not inner_content: + return [] + elif inner_content == "{}": + return [{}] + else: + # Check if it contains -> syntax (either direct or nested) + if "->" in inner_content: + # Direct arrow syntax like PROJECT->[...] + if not inner_content.startswith("["): + return parse_arrow_structure(inner_content) + + # Complex array with nested arrow structures + items = split_respecting_nesting(inner_content, ",") + parsed_items = [] + + for item in items: + item = item.strip() + if item.startswith("[") and item.endswith("]") and "->" in item: + # Nested structure like [PROJECT->[...]] + nested_inner = item[1:-1].strip() + parsed_items.append(parse_arrow_structure(nested_inner)) + else: + parsed_items.append(parse_parameter_value(item)) + + return parsed_items + + # Check if it's a simple array (no complex nested structures) + elif not any( + item.strip().startswith("[") and item.strip().endswith("]") + for item in split_respecting_nesting(inner_content, ",") + ): + # Keep simple arrays as string representation + return value_str + + # Parse other complex array elements + else: + items = split_respecting_nesting(inner_content, ",") + return [parse_parameter_value(item.strip()) for item in items] + + # Only parse as function calls if they contain key=value parameters + elif ( + "(" in value_str + and value_str.endswith(")") + and "=" in value_str[value_str.find("(") + 1 : value_str.rfind(")")] + ): + paren_pos = value_str.find("(") + func_name = value_str[:paren_pos].strip() + params_str = value_str[paren_pos + 1 : -1].strip() + + if params_str: + params = parse_parameters(params_str) + return {func_name: params} + else: + return {func_name: {}} + + else: + return value_str diff --git a/src/main/python/opensearchsql_cli/query/query_results.py b/src/main/python/opensearchsql_cli/query/query_results.py new file mode 100644 index 0000000..e6b771a --- /dev/null +++ b/src/main/python/opensearchsql_cli/query/query_results.py @@ -0,0 +1,155 @@ +""" +Query Result Handling + +This module provides table formatting for query results. +Other formats are handled by the Java formatters in the SQL library. +""" + +import json +from rich.console import Console +from rich.table import Table +from rich.box import HEAVY_HEAD + +# Create a console instance for rich formatting +console = Console() + + +class QueryResults: + """ + Class for formatting execute query results + """ + + def display_table_result(table_data, print_function=None): + """ + Display table result using the provided print function or console.print + + Args: + table_data: Dictionary containing table data from table_format + print_function: Function to use for printing (default: console.print) + """ + if print_function is None: + print_function = console.print + + # Check if there's an error + if "error" in table_data and table_data["error"]: + print_function(f"[bold red]Error:[/bold red] {table_data['message']}") + return + + # Print the message + print_function(table_data["message"]) + + # Print the table + if "result" in table_data and ( + "calcite" in table_data["result"].lower() + or "root" in table_data["result"].lower() + ): + # For explain results, just print the raw result + print_function(table_data["result"]) + elif table_data.get("vertical", False) and "tables" in table_data: + # For vertical format, print each table + for table in table_data["tables"]: + print_function(table) + print_function("") # Add a blank line between tables + elif "table" in table_data: + # For horizontal format, print the single table + print_function(table_data["table"]) + else: + # Fallback for unexpected data structure + print_function("[bold red]Error:[/bold red] Unable to display table data") + + # Print warning if present + if table_data.get("warning"): + print_function(table_data["warning"]) + + def table_format(result: str, vertical: bool = False): + """ + Format the result as a table using Rich Table + + Args: + result: JSON result string from Java in JDBC format + vertical: Whether to force vertical output format (default: False) + + Returns: + dict: Dictionary containing message, table object, and warning + """ + try: + data = json.loads(result) + + if isinstance(data, dict) and "schema" in data and "datarows" in data: + # Extract schema and data + schema = data["schema"] + datarows = data["datarows"] + total_hits = data["total"] + cur_size = data["size"] + + # Create message + message = f"Fetched {cur_size} rows with a total of {total_hits} hits" + + if vertical: + # Create a list to hold all record tables + record_tables = [] + + # Vertical format (one row per record) + for row_idx, row in enumerate(datarows): + record_table = Table( + title=f"RECORD {row_idx + 1}", + title_style="bold yellow", + box=HEAVY_HEAD, + show_header=False, + header_style="bold green", + show_lines=True, + border_style="bright_black", + ) + record_table.add_column("Field", style="bold green") + record_table.add_column("Value", style="white") + + for field_idx, field in enumerate(schema): + field_name = field.get("alias", field["name"]) + value = row[field_idx] if field_idx < len(row) else "" + value_str = str(value) if value is not None else "" + record_table.add_row(field_name, value_str) + + record_tables.append(record_table) + + return { + "message": message, + "tables": record_tables, + "vertical": True, + } + else: + # Horizontal format (traditional table) + table = Table( + box=HEAVY_HEAD, + show_header=True, + header_style="bold green", + show_lines=True, + border_style="bright_black", + ) + + # Add columns with styling + for field in schema: + field_name = field.get("alias", field["name"]) + table.add_column(field_name, style="bold green") + + # Add data rows + for row in datarows: + # Convert all values to strings + str_row = [ + str(val) if val is not None else "" for val in row + ] + table.add_row(*str_row, style="white") + + return {"message": message, "table": table, "vertical": False} + else: + # If not in a recognized format, return as is + return {"message": "Error formatting.", "error": True, "result": result} + except json.JSONDecodeError: + # If not valid JSON, return as is + return {"message": "Error decoding JSON.", "error": True, "result": result} + except Exception as e: + # Handle other exceptions + return { + "message": f"Error during formatting: {e}", + "error": True, + "result": result, + } diff --git a/src/main/python/opensearchsql_cli/query/saved_queries.py b/src/main/python/opensearchsql_cli/query/saved_queries.py new file mode 100644 index 0000000..7d51d4c --- /dev/null +++ b/src/main/python/opensearchsql_cli/query/saved_queries.py @@ -0,0 +1,385 @@ +""" +Saved Queries Management + +This module provides functionality to save, load, and list query results. +""" + +import os +import json +import traceback +from rich.console import Console +from datetime import datetime +from .execute_query import ExecuteQuery +from ..config.config import config_manager + +# Create a console instance for rich formatting +console = Console() + + +class SavedQueries: + """ + Class for managing saved queries and their results + """ + + def __init__(self, base_dir=None): + """ + Initialize SavedQueries instance + + Args: + base_dir: Base directory for saved queries files (default: root directory/save_query) + """ + if base_dir is None: + module_dir = os.path.dirname(__file__) + self.base_dir = os.path.join(module_dir, "save_query") + else: + self.base_dir = base_dir + + # Ensure the directory exists + if not os.path.exists(self.base_dir): + try: + os.makedirs(self.base_dir) + except OSError: + console.print( + f"[bold yellow]WARNING:[/bold yellow] [yellow]Could not create directory[/yellow] {self.base_dir}" + ) + + # Get saved query file path from config or use default + config_saved_query = config_manager.get("File", "saved_query", "") + if config_saved_query and config_saved_query.strip(): + self.saved_file = config_saved_query + else: + # Use default saved query file path + self.saved_file = os.path.join(self.base_dir, "saved.txt") + + # Create file if it doesn't exist + if not os.path.exists(self.saved_file): + try: + # Create directory if it doesn't exist + os.makedirs(os.path.dirname(self.saved_file), exist_ok=True) + with open(self.saved_file, "w") as f: + json.dump({}, f) + except IOError: + console.print( + f"[bold yellow]WARNING:[/bold yellow] [yellow]Could not create file[/yellow] {self.saved_file}" + ) + + def _load_saved_data(self): + """ + Load saved queries data from file + + Returns: + dict: Dictionary of saved queries + """ + try: + with open(self.saved_file, "r") as f: + return json.load(f) + except (IOError, json.JSONDecodeError): + return {} + + def _save_data(self, data): + """ + Save queries data to file + + Args: + data: Dictionary of saved queries + + Returns: + bool: True if successful, False otherwise + """ + try: + with open(self.saved_file, "w") as f: + json.dump(data, f, indent=2) + return True + except IOError: + console.print( + f"[bold red]ERROR:[/bold red] [red]Could not write to file[/red] {self.saved_file}" + ) + return False + + def save_query(self, name, query, language): + """ + Save a query + + Args: + name: Name to save the query under + query: The query string + language: Query language (PPL or SQL) + + Returns: + bool: - True if success, else False + """ + # Load existing saved queries + saved_data = self._load_saved_data() + + # Check if name already exists + if name in saved_data: + console.print(f"A query with name '[green]{name}[/green]' already exists.") + console.print(f"[red]\nUse a different name or replace it.[/red]") + return False + + # Add new saved query + saved_data[name] = { + "query": query, + "language": language, + "timestamp": datetime.now().isoformat(), + } + + # Save updated data + if self._save_data(saved_data): + console.print(f"Query saved as '[green]{name}[/green]'") + return True + else: + console.print( + f"[bold red]ERROR:[/bold red] [red]Could not save query[/red] [white](name: {name})[/white" + ) + return False + + def replace_query(self, name, query, language): + """ + Replace an existing saved query + + Args: + name: Name of the query to replace + query: The new query string + language: Query language (PPL or SQL) + + Returns: + bool: True if successful, False otherwise + """ + # Load existing saved queries + saved_data = self._load_saved_data() + + # Check if name exists + if name not in saved_data: + console.print( + f"[bold red]ERROR:[/bold red] [red]No query named[/red] '[white]{name}[/white]' [red]exists.[/red]" + ) + return False + + # Update saved query + saved_data[name] = { + "query": query, + "language": language, + "timestamp": datetime.now().isoformat(), + } + + # Save updated data + if self._save_data(saved_data): + console.print(f"Query '[green]{name}[/green]' replaced") + return True + else: + console.print( + f"[bold red]ERROR:[/bold red] [red]Failed to replace query[/red] '[green]{name}[/green]'" + ) + return False + + def load_query(self, name): + """ + Load a saved query + + Args: + name: Name of the query to load + + Returns: + tuple: (bool, dict) - (success, query_data) + """ + # Load existing saved queries + saved_data = self._load_saved_data() + + # Check if name exists + if name not in saved_data: + console.print( + f"[bold red]ERROR:[/bold red] Saved Query '[green]{name}[/green]' does not exist." + ) + return (False, None) + + return (True, saved_data[name]) + + def remove_query(self, name): + """ + Remove a saved query + + Args: + name: Name of the query to remove + + Returns: + bool: True if successful, False otherwise + """ + # Load existing saved queries + saved_data = self._load_saved_data() + + # Check if name exists + if name not in saved_data: + console.print( + f"[bold red]ERROR:[/bold red] Saved Query '[green]{name}[/green]' does not exist." + ) + return False + + # Remove the query + del saved_data[name] + + # Save updated data + if self._save_data(saved_data): + console.print(f"Query '[green]{name}[/green]' removed") + return True + else: + console.print( + f"[bold red]ERROR:[/bold red] [red]Unable to remove[/red] '[green]{name}[/green]'" + ) + return False + + def list_queries(self): + """ + List all saved queries + + Returns: + dict: Dictionary of saved queries + """ + return self._load_saved_data() + + def saving_query(self, name, latest_query, language_mode): + """ + Save a query with confirmation for overwriting if it already exists + + Args: + name: Name to save the query under + latest_query: The query string + language_mode: Query language (PPL or SQL) + + Returns: + bool: True if successful, False otherwise + """ + # Check if there's a query to save + if not latest_query: + console.print( + "[bold red]ERROR:[/bold red] [red]Please execute a query first.[/red]" + ) + return False + + # Check if name already exists + saved_data = self._load_saved_data() + if name in saved_data: + # Ask user to choose another name or replace it + console.print( + f"A query with name '[green]{name}[/green]' already exists. [red]\nUse a different name or replace it.[/red]" + ) + console.print( + f"Do you want to replace saved query '[green]{name}[/green]'? (y/n): ", + end="", + ) + confirm = input().lower() + if confirm == "y" or confirm == "yes": + return self.replace_query(name, latest_query, language_mode) + else: + console.print( + f"Query '[green]{name}[/green]' was [red]NOT[/red] replaced." + ) + return False + else: + # Save new query + return self.save_query(name, latest_query, language_mode) + + def loading_query(self, name, connection, format="table", is_vertical=False): + """ + Load and execute a saved query + + Args: + name: Name of the query to load + connection: Connection object to execute the query + format: Output format (json, table, csv) + is_vertical: Whether to display results in vertical format + + Returns: + bool: True if successful, False otherwise + """ + + success, query_data = self.load_query(name) + + if not success: + return False, "", "", "" + + query = query_data.get("query", "") + language = query_data.get("language", "PPL") + is_ppl_mode = language.upper() == "PPL" + is_explain = query.strip().lower().startswith("explain") + + try: + # Execute query using ExecuteQuery class + success, result, formatted_result = ExecuteQuery.execute_query( + connection, + query, + is_ppl_mode, + is_explain, + format, + is_vertical, + console.print, + ) + return success, query, formatted_result, language + + except Exception as e: + console.print( + f"[bold red]ERROR:[/bold red] [red] Unable to execute [/red] {e}" + ) + traceback.print_exc() + return False, "", "", "" + + def removing_query(self, name): + """ + Remove a saved query with confirmation + + Args: + name: Name of the query to remove + + Returns: + bool: True if successful, False otherwise + """ + # Check if the query exists first + saved_queries = self.list_queries() + if name not in saved_queries: + console.print( + f"[bold red]ERROR:[/bold red] [red]Query[/red] '[green]{name}[/green]' [red]not found.[/red]" + ) + return False + + # Ask for confirmation before removing + console.print( + f"Are you sure you want to remove saved query '[green]{name}[/green]'? (y/n): ", + end="", + ) + confirm = input().lower() + if confirm == "y" or confirm == "yes": + return self.remove_query(name) + else: + console.print(f"Query '[green]{name}[/green]' was [red]NOT[/red] removed.") + return False + + def list_saved_queries(self): + """ + List all saved queries with formatted output + + Returns: + bool: True if queries were found and listed, False otherwise + """ + saved_queries = self.list_queries() + + if not saved_queries: + console.print("[yellow]No saved queries found.[/yellow]") + return False + + for name, data in saved_queries.items(): + query = data.get("query", "Unknown") + timestamp = data.get("timestamp", "Unknown") + + if timestamp != "Unknown": + try: + # Convert ISO format to datetime object + dt = datetime.fromisoformat(timestamp) + timestamp = dt.strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, AttributeError): + pass + + console.print(f"\n- [green]{name}[/green]") + console.print(f"\t[yellow]{query}[/yellow]") + console.print(f"\t[dim white]{timestamp}[/dim white]") + + return True diff --git a/src/main/python/opensearchsql_cli/sql/__init__.py b/src/main/python/opensearchsql_cli/sql/__init__.py new file mode 100644 index 0000000..ffa6eaf --- /dev/null +++ b/src/main/python/opensearchsql_cli/sql/__init__.py @@ -0,0 +1,10 @@ +""" +SQL Library Package for OpenSearch SQL CLI + +This package provides functionality for SQL and PPL query execution in SQL Library and its connection management. +""" + +from .sql_connection import sql_connection +from .sql_library_manager import sql_library_manager +from .sql_version import sql_version +from .verify_cluster import VerifyCluster diff --git a/src/main/python/opensearchsql_cli/sql/sql_connection.py b/src/main/python/opensearchsql_cli/sql/sql_connection.py new file mode 100644 index 0000000..4d962b1 --- /dev/null +++ b/src/main/python/opensearchsql_cli/sql/sql_connection.py @@ -0,0 +1,268 @@ +""" +SQL Library Connection Management + +Handles connection to SQL library and OpenSearch Cluster configuration. +""" + +from py4j.java_gateway import JavaGateway, GatewayParameters +import sys +from rich.console import Console +from .sql_library_manager import sql_library_manager +from .verify_cluster import VerifyCluster +from .sql_version import sql_version +from ..config.config import config_manager + +# Create a console instance for rich formatting +console = Console() + + +class SqlConnection: + """ + SqlConnection class for managing SQL library and OpenSearch connections + """ + + def __init__(self, port=25333): + """ + Initialize a Connection instance + + Args: + port: Gateway port (default 25333) + """ + self.gateway_port = port + self.sql_lib = None + self.sql_connected = False + self.opensearch_connected = False + self.error_message = None + + # Connection parameters + self.host = None + self.port_num = None + self.protocol = "http" + self.username = None + self.password = None + + # Store OpenSearch verification results + self.cluster_version = None + self.url = None + self.client = None + + def verify_opensearch_connection( + self, host_port=None, username_password=None, ignore_ssl=False, aws_auth=False + ): + """ + Verify connection to an OpenSearch cluster + + Args: + host_port: Optional host:port string for OpenSearch Cluster connection + username_password: Optional username:password string for authentication + ignore_ssl: Whether to ignore SSL certificate validation + aws_auth: Whether to use AWS SigV4 authentication + + Returns: + bool: True if successful, False otherwise + """ + try: + # Parse username_password if provided + if username_password and ":" in username_password: + self.username, self.password = username_password.split(":", 1) + + if aws_auth: + # AWS SigV4 authentication + if not host_port: + console.print( + "[bold red]ERROR:[/bold red] [red]URL is required for AWS Authentication[/red]" + ) + return False + + # Remove protocol prefix if present + if "://" in host_port: + self.protocol, host_port = host_port.split("://", 1) + + # Store the AWS host + self.host = host_port + + # Verify AWS connection + success, message, cluster_version, url, region, client = ( + VerifyCluster.verify_aws_opensearch_connection(host_port) + ) + if not success: + self.error_message = message + return False + + # Store connection information + self.cluster_version = cluster_version + self.url = url + self.client = client + self.username = ( + f"{region}" # Use region as the "username" for AWS connections + ) + return True + elif host_port: + # Handle URLs with protocol + if "://" in host_port: + self.protocol, host_port = host_port.split("://", 1) + + # Parse host and port + if ":" in host_port: + self.host, port_str = host_port.split(":", 1) + + try: + self.port_num = int(port_str) + except ValueError: + console.print( + f"[bold red]ERROR:[/bold red] [red]Invalid port: {port_str}[/red]" + ) + return False + else: + self.host = host_port + # Set default port based on protocol + if self.protocol.lower() == "http": + self.port_num = 9200 + elif self.protocol.lower() == "https": + self.port_num = 443 + + # Verify connection using parsed values + success, message, cluster_version, url, username, client = ( + VerifyCluster.verify_opensearch_connection( + self.host, + self.port_num, + self.protocol, + self.username, + self.password, + ignore_ssl, + ) + ) + if not success: + self.error_message = message + return False + + # Store connection information + self.cluster_version = cluster_version + self.url = url + self.client = client + if username: + self.username = username + + return True + + return False + + except Exception as e: + self.error_message = f"Unable to connect to {host_port}: {str(e)}" + return False + + def initialize_sql_library( + self, host_port=None, username_password=None, ignore_ssl=False, aws_auth=False + ): + """ + Initialize SQL Library with OpenSearch connection parameters. + This is called after verify_opensearch_connection has succeeded. + This will also connect to the SQL library if it's not already connected. + + Args: + host_port: Optional host:port string for OpenSearch Cluster connection + username_password: Optional username:password string for authentication + ignore_ssl: Whether to ignore SSL certificate validation + aws_auth: Whether to use AWS SigV4 authentication + + Returns: + bool: True if successful, False otherwise + """ + # Connect to the SQL library if not already connected + if not self.sql_connected or not self.sql_lib: + if not self.connect(): + return False + + try: + # Get HTTP version flag from sql_version + use_http5 = sql_version.use_http5 + + # Initialize the connection in Java based on the verification results + if aws_auth: + result = self.sql_lib.entry_point.initializeAwsConnection( + self.host, + use_http5, + ) + else: + result = self.sql_lib.entry_point.initializeConnection( + self.host, + self.port_num, + self.protocol, + self.username, + self.password, + ignore_ssl, + use_http5, + ) + + # Check for successful initialization + self.opensearch_connected = result + self.error_message = ( + "Failed to initialize SQL library" if not result else None + ) + return result + + except Exception as e: + self.error_message = f"Unable to initialize SQL library: {str(e)}" + self.opensearch_connected = False + return False + + def connect(self): + """ + Connect to the SQL library + + Returns: + bool: True if connection successful, False otherwise + """ + try: + # Start the SQL Library server if it's not already running + if not sql_library_manager.started: + if not sql_library_manager.start(): + console.print("[bold red]Failed to connect SQL Library[/bold red]") + return False + + # Connect to the SQL Library + self.sql_lib = JavaGateway( + gateway_parameters=GatewayParameters(port=self.gateway_port) + ) + self.sql_connected = True + return True + except Exception as e: + console.print( + f"[bold red]Failed to connect to SQL on port {self.gateway_port}: {e}[/bold red]" + ) + self.sql_connected = False + return False + + def query_executor(self, query, is_ppl=True, is_explain=False, format="json"): + """ + Execute a query through the SQL Library service + + Args: + query: The SQL or PPL query string + is_ppl: True if the query is PPL, False if SQL (default: True) + is_explain: True if query is explain (default: False) + format: Output format (json, table, csv) (default: json) + + Returns: + Query result string formatted according to the specified format + """ + if not self.sql_connected or not self.sql_lib: + console.print( + "[bold red]ERROR:[/bold red] [red]Unable to connect to SQL library[/red]" + ) + return "Error: Not connected to SQL library" + + if not self.opensearch_connected: + console.print( + "[bold red]ERROR:[/bold red] [red]Unable to connect to OpenSearch Cluster[/bold red]" + ) + return "Error: Not connected to OpenSearch Cluster" + + query_service = self.sql_lib.entry_point + # queryExecution inside of Gateway.java + result = query_service.queryExecution(query, is_ppl, is_explain, format) + return result + + +# Create a global connection instance +sql_connection = SqlConnection() diff --git a/src/main/python/opensearchsql_cli/sql/sql_library_manager.py b/src/main/python/opensearchsql_cli/sql/sql_library_manager.py new file mode 100644 index 0000000..d5213f4 --- /dev/null +++ b/src/main/python/opensearchsql_cli/sql/sql_library_manager.py @@ -0,0 +1,294 @@ +""" +SQL Library Management + +Handles initialization and cleanup of the SQL Library. +""" + +import os +import sys +import atexit +import logging +import subprocess +import time +import threading +import socket +from datetime import datetime +from .sql_version import sql_version +from ..config.config import config_manager + +# sql-cli/src/main/python/opensearchsql_cli/sql +current_dir = os.path.dirname(os.path.abspath(__file__)) +# sql-cli/ +PROJECT_ROOT = os.path.normpath(os.path.join(current_dir, "../../../../../")) +JAVA_DIR = os.path.join(PROJECT_ROOT, "src", "main", "java") +AWS_DIR = os.path.join(JAVA_DIR, "client", "http5", "aws") +LOGBACK_CONFIG = os.path.abspath( + os.path.join(PROJECT_ROOT, "src", "main", "resources", "logback.xml") +) + + +class SqlLibraryManager: + """ + Manages the SQL Library initialization and cleanup + """ + + def __init__(self, port=25333): + """ + Initialize the SQL Library manager + + Args: + port: Port (default 25333) + """ + self.gateway_port = port + self.started = False + self.process = None + self.output_thread = None + self.thread_running = False + + # Register cleanup function + atexit.register(self.stop) + + def _check_port_in_use(self): + """ + Check if the port is already in use + + Returns: + bool: True if port is in use, False otherwise + """ + try: + # Try to create a socket and bind to the port + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(1) + result = s.connect_ex(("localhost", self.gateway_port)) + return result == 0 # If result is 0, port is in use + except Exception as e: + if hasattr(self, "logger"): + self.logger.error(f"Error checking port {self.gateway_port}: {e}") + return False + + def _kill_process_on_port(self): + """ + Kill any process using the specified port + + Returns: + bool: True if process was killed or no process found, False on error + """ + try: + if sys.platform.startswith("win"): + # Windows approach + cmd = f"for /f \"tokens=5\" %a in ('netstat -ano ^| findstr :{self.gateway_port}') do taskkill /F /PID %a" + subprocess.run(cmd, shell=True) + else: + # Unix/Mac approach + cmd = f"lsof -i :{self.gateway_port} | grep LISTEN | awk '{{print $2}}' | xargs -r kill -9" + subprocess.run(cmd, shell=True) + + if hasattr(self, "logger"): + self.logger.info(f"Killed any process using port {self.gateway_port}") + return True + except Exception as e: + if hasattr(self, "logger"): + self.logger.error( + f"Error killing process on port {self.gateway_port}: {e}" + ) + return False + + def _setup_logging(self): + """ + Set up logging for the SQL Library + + Returns: + bool: True if setup successful, False otherwise + """ + try: + # Get log file path from config or use default + config_log_file = config_manager.get("File", "sql_log", "") + if config_log_file and config_log_file.strip(): + log_file = config_log_file + else: + # Use default log file path + log_file = os.path.join(PROJECT_ROOT, "logs", "sql_library.log") + + # Create directory if it doesn't exist + os.makedirs(os.path.dirname(log_file), exist_ok=True) + + self.logger = logging.getLogger("sql_library") + self.logger.setLevel(logging.INFO) + + # Create file handler + file_handler = logging.FileHandler(log_file, mode="a") + file_handler.setFormatter( + logging.Formatter("%(asctime)s %(message)s", datefmt="%H:%M:%S") + ) + self.logger.addHandler(file_handler) + + # Log startup information + self.logger.info("=" * 80) + self.logger.info(f"Initializing SQL Library on {time.strftime('%Y-%m-%d')}") + + return True + except Exception as e: + print(f"Error setting up logging: {e}") + return False + + def _wait_for_server_start(self): + """ + Wait for the server to start + + Returns: + bool: True if server started, False if timeout + """ + for _ in range(30): + line = self.process.stdout.readline() + self.logger.info(line.strip()) + if "Gateway Server Started" in line: + return True + + self.logger.error("Failed to start Gateway server within timeout") + return False + + def _start_output_thread(self): + """ + Start a thread to monitor process output + """ + self.thread_running = True + + def read_output(): + try: + while ( + self.thread_running and self.process and self.process.poll() is None + ): + line = self.process.stdout.readline() + if line: + self.logger.info(line.strip()) + except Exception as e: + if hasattr(self, "logger"): + self.logger.error(f"Error in output thread: {e}") + + self.output_thread = threading.Thread(target=read_output, daemon=True) + self.output_thread.start() + + def start(self): + """ + Initialize the SQL Library + + Returns: + bool: True if initialization successful, False otherwise + """ + if self.started: + return True + + self.process = None + + try: + # Always attempt to kill any process using the port + if self._check_port_in_use(): + if not self._kill_process_on_port(): + return False + + # Set up logging + if not self._setup_logging(): + return False + + jar_path = sql_version.get_jar_path() + + # Add logback configuration + self.logger.info(f"Using logback config: {LOGBACK_CONFIG}") + + cmd = [ + "java", + "-Dlogback.configurationFile=" + str(LOGBACK_CONFIG), + "-jar", + jar_path, + "Gateway", + ] + self.logger.info(f"Using JAR file: {jar_path}") + self.logger.info(f"Command: {' '.join(cmd)}") + + # Start the process + self.process = subprocess.Popen( + cmd, + cwd=PROJECT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + universal_newlines=True, + ) + + # Wait for the server to start + if not self._wait_for_server_start(): + self.stop() + return False + + # Start output monitoring thread + self._start_output_thread() + + self.started = True + self.logger.info("SQL Library initialized successfully") + return True + + except Exception as e: + error_msg = f"Failed to initialize SQL Library: {e}" + if hasattr(self, "logger"): + self.logger.error(error_msg) + return False + + def stop(self): + """ + Clean up SQL Library resources + + Returns: + bool: True if cleanup successful, False otherwise + """ + if not self.started: + return True + + try: + # Signal the output thread to stop + self.thread_running = False + + if self.process: + if sys.platform.startswith("win"): + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(self.process.pid)] + ) + else: + self.process.kill() + + # Wait for the process to terminate + try: + self.process.wait(timeout=2) + except subprocess.TimeoutExpired: + pass + + self.process = None + + # Wait for the output thread to finish + if self.output_thread and self.output_thread.is_alive(): + try: + self.output_thread.join(timeout=1) + except Exception: + pass + self.output_thread = None + + self.started = False + + aws_body_path = os.path.join(AWS_DIR, "aws_body.json") + if os.path.exists(aws_body_path): + os.remove(aws_body_path) + if self.logger: + self.logger.info("Delete " + aws_body_path) + + if self.logger: + self.logger.info("SQL Library resources cleaned up") + return True + + except Exception as e: + if self.logger: + self.logger.error(f"Error stopping SQL Library: {e}") + return False + + +# Create a global instance +sql_library_manager = SqlLibraryManager() diff --git a/src/main/python/opensearchsql_cli/sql/sql_version.py b/src/main/python/opensearchsql_cli/sql/sql_version.py new file mode 100644 index 0000000..7f4e1db --- /dev/null +++ b/src/main/python/opensearchsql_cli/sql/sql_version.py @@ -0,0 +1,446 @@ +""" +SQL Version Management + +Handles version selection for OpenSearch CLI. +""" + +import os +import re +import subprocess +import requests +import shutil +from pathlib import Path +from bs4 import BeautifulSoup +from packaging import version +from rich.console import Console +from rich.status import Status + +# Create a console instance for rich formatting +console = Console() + +# sql-cli/src/main/python/opensearchsql_cli/sql +current_dir = os.path.dirname(os.path.abspath(__file__)) +# sql-cli/ +PROJECT_ROOT = os.path.normpath(os.path.join(current_dir, "../../../../../")) +# Java directory: sql-cli/src/main/java +JAVA_DIR = os.path.join(PROJECT_ROOT, "src", "main", "java") + + +class SqlVersion: + """ + Manages SQL version selection for OpenSearch CLI + """ + + def __init__(self): + """ + Initialize the SQL Version manager + """ + self.available_versions = self.get_all_versions() + # Default version is the highest version number + self.version = self.get_latest_version() + # Default to HTTP5 for version 3.x and above + # HTTP4 for older versions + self.use_http5 = self._should_use_http5(self.version) + + def get_all_versions(self): + """ + Get all available versions from the repository website + + Returns: + list: List of all available versions + """ + url = "https://aws.oss.sonatype.org/content/repositories/snapshots/org/opensearch/query/unified-query-core/maven-metadata.xml" + + response = requests.get(url) + response.raise_for_status() + + soup = BeautifulSoup(response.text, features="xml") + + versions = [] + + for version_tag in soup.find_all("version"): + version_str = version_tag.text + version_str = version_str.replace("-SNAPSHOT", "") + versions.append(version_str) + + return versions + + def get_latest_version(self): + """ + Get the latest version from the repository website + + Returns: + str: The latest version string or None if no versions found + """ + versions = self.get_all_versions() + + if not versions: + return None + + try: + parsed_versions = [(v, version.parse(v)) for v in versions] + # Sort by parsed version objects with descending order + parsed_versions.sort(key=lambda x: x[1], reverse=True) + + return parsed_versions[0][0] + except Exception as e: + # Falling back to string sorting + versions.sort(reverse=True) + return versions[0] + + def get_jar_path(self): + """ + Get the path to the JAR file for the specified version + + Returns: + str: Path to the JAR file + """ + return os.path.join( + PROJECT_ROOT, "build", "libs", f"opensearchsqlcli-{self.version}.jar" + ) + + def set_version(self, version, rebuild=False): + """ + Set the version of OpenSearch to use + + Args: + version: Version string (e.g., "3.1", "3.0.0.0-alpha1") + rebuild: If True, rebuild the JAR even if it exists + + Returns: + bool: True if version is valid, False otherwise + """ + self.version = self._normalize_version(version) + self.use_http5 = self._should_use_http5(self.version) + + if self.version not in self.available_versions: + console.print( + f"[bold red]ERROR:[/bold red] [red]Version {version} is currently not supported[/red]" + ) + console.print( + f"[red]Available versions: {', '.join(self.available_versions)}[/red]" + ) + return False + + return self._build_sqlcli_jar(rebuild=rebuild) + + def set_remote_version( + self, branch_name, git_url, rebuild=False, remote_output=None + ): + """ + Set the version by cloning a git repository and building from it + + Args: + branch_name: Git branch name to clone + git_url: Git repository URL + rebuild: If True, rebuild the JAR even if it exists + remote_output: Custom directory to clone the repository into (optional) + + Returns: + bool: True if successful, False otherwise + """ + # Extract repository name from git URL + # Example: https://github.com/opensearch-project/sql.git -> sql + repo_name = os.path.basename(git_url) + if repo_name.endswith(".git"): + repo_name = repo_name[:-4] # Remove .git suffix + + if remote_output: + git_dir = remote_output + else: + git_dir = os.path.join(PROJECT_ROOT, "remote", repo_name) + + success, result = self._clone_repository(branch_name, git_url, git_dir) + + if not success: + if "fatal: destination path" in result.stderr: + console.print( + f"[bold yellow]INFO:[/bold yellow] [yellow]Directory already exists: {git_dir}[/yellow]" + ) + reclone = ( + input("Do you want to delete and reclone the repository? (y/n): ") + .strip() + .lower() + ) + + if reclone == "y" or reclone == "yes": + shutil.rmtree(git_dir) + success, result = self._clone_repository( + branch_name, git_url, git_dir + ) + rebuild = True + + if not success: + console.print( + f"[bold red]ERROR:[/bold red] [red]Failed to clone repository: {result.stderr}[/red]" + ) + return False + else: + console.print( + f"[bold yellow]INFO:[/bold yellow] [yellow]Using existing directory: {git_dir}[/yellow]" + ) + elif "git: command not found" in result.stderr: + console.print( + f"[bold red]ERROR:[/bold red] [red]Git is not installed or not found in PATH[/red]" + ) + console.print( + f"[yellow]Please install Git by following the guide:[/yellow] [blue]https://github.com/git-guides/install-git[/blue]" + ) + console.print(f"[blue]https://github.com/git-guides/install-git[/blue]") + return False + else: + console.print( + f"[bold red]ERROR:[/bold red] [red]Failed to clone repository: {result.stderr}[/red]" + ) + return False + + return self.set_local_version(git_dir, rebuild) + + def set_local_version(self, local_dir, rebuild=False): + """ + Set the version using a local directory + + Args: + local_dir: Path to directory containing the SQL project + rebuild: If True, rebuild the JAR even if it exists + + Returns: + bool: True if successful, False otherwise + """ + if not os.path.exists(local_dir): + console.print( + f"[bold red]ERROR:[/bold red] [red]Directory {local_dir} does not exist[/red]" + ) + return False + + if rebuild and not self._build_sql_jars(local_dir): + return False + + jar_file = self._find_sql_jar(local_dir) + + if not jar_file: + if not self._build_sql_jars(local_dir): + return False + + jar_file = self._find_sql_jar(local_dir) + + if not jar_file: + return False + + self.version = self._extract_version_from_jar(jar_file) + self.use_http5 = self._should_use_http5(self.version) + + return self._build_sqlcli_jar(rebuild=rebuild, local_dir=local_dir) + + def _normalize_version(self, version_str): + """ + Normalize version string to ensure it has 4 parts + + Args: + version_str: Version string to normalize + + Returns: + str: Normalized version string + """ + if "-" in version_str: + return version_str + + parts = version_str.split(".") + while len(parts) < 4: + parts.append("0") + return ".".join(parts) + + def _should_use_http5(self, version_str): + """ + Determine if HTTP5 should be used based on version + + Args: + version_str: Version string + + Returns: + bool: True if HTTP5 should be used, False for HTTP4 + """ + try: + major_version = int(version_str.split(".")[0]) + # Use HTTP5 for version 3.x and above + return major_version >= 3 + except (ValueError, IndexError): + return True + + def _extract_version_from_jar(self, jar_file): + """ + Extract version from JAR filename + + Args: + jar_file: JAR filename (e.g., opensearch-sql-3.1.0.0-SNAPSHOT.jar) + + Returns: + str: Extracted version (e.g., 3.1.0.0) or latest version if extraction fails + """ + match = re.search(r"opensearch-sql-([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", jar_file) + if match: + return match.group(1) + else: + version = self.get_latest_version() + console.print( + f"[bold yellow]WARNING:[/bold yellow] [yellow]Could not extract version from JAR filename, using latest version: {version}[/yellow]" + ) + return version + + def _find_sql_jar(self, local_dir): + """ + Find SQL JAR file in the distributions directory + + Args: + local_dir: Path to directory containing the SQL project + + Returns: + str: JAR filename if found, None otherwise + """ + distributions_dir = os.path.join(local_dir, "build", "distributions") + + if os.path.exists(distributions_dir): + for f in os.listdir(distributions_dir): + if f.endswith(".jar") and f.startswith("opensearch-sql-"): + return f + + return None + + def _build_sql_jars(self, local_dir): + """ + Build SQL JARs in the local directory by running ./gradlew clean assemble + + Args: + local_dir: Path to directory containing the SQL project + + Returns: + bool: True if successful, False otherwise + """ + with console.status( + f"[bold yellow]Creating SQL JARs in {local_dir}...[/bold yellow]", + spinner="dots", + ): + log_file = os.path.join(PROJECT_ROOT, "logs", "sql_build.log") + os.makedirs(os.path.dirname(log_file), exist_ok=True) + with open(log_file, "w") as file: + result = subprocess.run( + ["./gradlew", "clean", "assemble"], + cwd=local_dir, + stdout=file, + stderr=subprocess.STDOUT, + ) + + if result.returncode != 0: + console.print( + f"[bold red]ERROR:[/bold red] [red]Failed to build from {local_dir}[/red]" + ) + console.print( + f"[red]Please check file [blue]sql_build.log[/blue] for more information[/red]" + ) + return False + + console.print( + f"[bold green]SUCCESS:[/bold green] [green]Built SQL JARs in {local_dir}[/green]" + ) + return True + + def _build_sqlcli_jar(self, rebuild=False, local_dir=None): + """ + Build the sqlcli JAR for the current version + + Args: + rebuild: If True, rebuild the JAR even if it exists + local_dir: Path to directory containing the SQL project (for local builds) + + Returns: + bool: True if successful, False otherwise + """ + jar_path = self.get_jar_path() + + version_parts = self.version.split(".") + gradle_task = f"{version_parts[0]}_{version_parts[1]}_{version_parts[2]}_{version_parts[3]}" + + # Add _local suffix for local builds + if local_dir: + gradle_task += "_local" + + # Run ./gradlew clean if rebuild + if rebuild: + subprocess.run( + ["./gradlew", "clean"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + + if not os.path.exists(jar_path): + cmd_args = ["./gradlew", gradle_task] + + # Add localJarDir property for local builds + if local_dir: + cmd_args.append(f"-PlocalJarDir={local_dir}") + + with console.status( + f"[bold yellow]Building SQL CLI v{self.version}...[/bold yellow]", + spinner="dots", + ): + log_file = os.path.join(PROJECT_ROOT, "logs", "sqlcli_build.log") + os.makedirs(os.path.dirname(log_file), exist_ok=True) + with open(log_file, "w") as file: + result = subprocess.run( + cmd_args, + cwd=PROJECT_ROOT, + stdout=file, + stderr=subprocess.STDOUT, + ) + + if not os.path.exists(jar_path): + console.print( + f"[bold red]ERROR:[/bold red] [red]Failed to build SQL CLI. JAR file does not exist at {jar_path}[/red]" + ) + console.print( + f"[red]Please check file [blue]sqlcli_build.log[/blue] for more information[/red]" + ) + return False + else: + console.print( + f"[bold green]SUCCESS:[/bold green] [green]Built SQL CLI at {jar_path}[/green]" + ) + + return True + + def _clone_repository(self, branch_name, git_url, git_dir): + """ + Clone a git repository + + Args: + branch_name: Git branch name to clone + git_url: Git repository URL + git_dir: Directory to clone into + + Returns: + tuple: (success, result) where success is a boolean and result is the subprocess.CompletedProcess object + """ + with console.status( + f"[bold yellow]Cloning repository {git_url} branch {branch_name}...[/bold yellow]", + spinner="dots", + ): + result = subprocess.run( + [ + "git", + "clone", + "--branch", + branch_name, + "--single-branch", + git_url, + git_dir, + ], + capture_output=True, + text=True, + ) + + return result.returncode == 0, result + + +# Create a global instance +sql_version = SqlVersion() diff --git a/src/main/python/opensearchsql_cli/sql/verify_cluster.py b/src/main/python/opensearchsql_cli/sql/verify_cluster.py new file mode 100644 index 0000000..d85148f --- /dev/null +++ b/src/main/python/opensearchsql_cli/sql/verify_cluster.py @@ -0,0 +1,204 @@ +""" +OpenSearch Connection Verification + +Handles verification of connections to OpenSearch clusters using opensearchpy. +""" + +import ssl +import urllib3 +import boto3 +import sys +import warnings +from rich.console import Console +from opensearchpy import OpenSearch, RequestsHttpConnection +from opensearchpy.exceptions import ( + ConnectionError, + RequestError, + AuthenticationException, + AuthorizationException, +) +from opensearchpy.connection import create_ssl_context +from requests_aws4auth import AWS4Auth + +# Disable SSL warnings from urllib3 and opensearchpy +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +warnings.filterwarnings( + "ignore", message="Connecting to .* using SSL with verify_certs=False is insecure" +) + +# Create a console instance for rich formatting +console = Console() + + +class VerifyCluster: + """ + Class for verifying connections to OpenSearch clusters. + """ + + @staticmethod + def get_indices(client): + """ + Get the list of indices from an OpenSearch cluster. + + Args: + client: OpenSearch client + print_list: Whether to print the list of indices + + Returns: + list: List of indices + """ + try: + if client: + res = client.indices.get_alias().keys() + indices = list(res) + return indices + return [] + except Exception as e: + console.print(f"[bold red]Error getting indices:[/bold red] {str(e)}") + return [] + + @staticmethod + def verify_opensearch_connection( + host, port, protocol="http", username=None, password=None, ignore_ssl=False + ): + """ + Verify connection to an OpenSearch cluster. + + Args: + host: OpenSearch host + port: OpenSearch port + protocol: Protocol (http or https) + username: Optional username for authentication + password: Optional password for authentication + ignore_ssl: Whether to ignore SSL certificate validation + + Returns: + tuple: (success, message, version, url, username, client) where: + - success: boolean indicating if the connection was successful + - message: string message about the connection status + - version: string version of OpenSearch if available, None otherwise + - url: string URL of the OpenSearch endpoint + - username: string username used for authentication if provided, None otherwise + - client: OpenSearch client if successful, None otherwise + """ + try: + # Build the URL + url = f"{protocol}://{host}:{port}" + + # Set up authentication if provided + http_auth = None + if username and password: + http_auth = (username, password) + + # Set up SSL context if needed + if protocol.lower() == "https": + ssl_context = create_ssl_context() + if ignore_ssl: + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + # Create OpenSearch client + client = OpenSearch( + [url], + http_auth=http_auth, + verify_certs=not ignore_ssl, + ssl_context=ssl_context, + connection_class=RequestsHttpConnection, + ) + else: + # Create OpenSearch client for HTTP + client = OpenSearch( + [url], + http_auth=http_auth, + verify_certs=False, + connection_class=RequestsHttpConnection, + ) + + # Get cluster info + info = client.info() + + # Extract version + version = None + if "version" in info and "number" in info["version"]: + version = info["version"]["number"] + + return True, "success", version, url, username, client + + except ( + AuthenticationException, + AuthorizationException, + ConnectionError, + Exception, + ) as e: + error_msg = f"Unable to connect {url}" + return False, error_msg, None, url, username, None + + @staticmethod + def verify_aws_opensearch_connection(host): + """ + Verify connection to an AWS OpenSearch Service or OpenSearch Serverless domain using opensearchpy. + + Args: + host: AWS OpenSearch host (without protocol) + + Returns: + tuple: (success, message, version, url, region, client) where: + - success: boolean indicating if the connection was successful + - message: string message about the connection status + - version: string version of OpenSearch if available, None otherwise + - url: string URL of the AWS OpenSearch endpoint + - region: string AWS region of the OpenSearch domain + - client: OpenSearch client if successful, None otherwise + """ + url = f"https://{host}" + is_serverless = "aos" in host + + try: + # Determine if this is OpenSearch Service or OpenSearch Serverless + service = "aoss" if is_serverless else "es" + + # Get AWS credentials and region + session = boto3.Session() + credentials = session.get_credentials() + region = session.region_name + + if not credentials: + error_msg = "Unable to retrieve AWS credentials." + return False, error_msg, None, url, None, None + + if not region: + error_msg = "Unable to retrieve AWS region." + return False, error_msg, None, url, None, None + + # Create AWS authentication + aws_auth = AWS4Auth( + credentials.access_key, + credentials.secret_key, + region, + service, + session_token=credentials.token, + ) + + # Create OpenSearch client + client = OpenSearch( + hosts=[url], + http_auth=aws_auth, + use_ssl=True, + verify_certs=True, + connection_class=RequestsHttpConnection, + ) + + # Get cluster info + info = client.info() + + # Extract version (serverless is versionless) + version = "Serverless" if is_serverless else info["version"]["number"] + + return True, "success", version, url, region, client + + except (ConnectionError, Exception) as e: + error_msg = f"Unable to connect {url}" + return False, error_msg, None, url, None, None + except Exception as e: + error_msg = f"AWS Connection Verification ERROR: {str(e)}" + return False, error_msg, None, url, None, None diff --git a/src/main/python/opensearchsql_cli/tests/README.md b/src/main/python/opensearchsql_cli/tests/README.md new file mode 100644 index 0000000..54df6cd --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/README.md @@ -0,0 +1,189 @@ +# OpenSearch SQL CLI Test Suite + +This directory contains the test suite for the OpenSearch SQL CLI project. The tests are organized to validate the functionality of various components of the CLI tool, ensuring that it works correctly and reliably. + +- [Test Structure](#test-structure) +- [Test Components](#test-components) +- [Test Dependencies](#test-dependencies) +- [Running Tests](#running-tests) +- [Warning Filters](#warning-filters) +- [Writing New Tests](#writing-new-tests) +- [Mocking Strategy](#mocking-strategy) +- [Test Coverage](#test-coverage) + +## Test Structure + +The test suite is organized into the following structure: + +``` +tests/ +├── __init__.py # Package initialization +├── conftest.py # Main pytest configuration and fixtures +├── pytest.init # Pytest initialization file +├── test_interactive.py # Tests for interactive shell functionality +├── test_main_commands.py # Tests for main CLI commands +├── config/ # Tests for configuration functionality +│ ├── __init__.py +│ ├── conftest.py # Config-specific fixtures +│ └── test_config.py +├── literals/ # Tests for literals functionality +│ ├── __init__.py +│ ├── conftest.py # Literals-specific fixtures +│ └── test_literals.py +├── query/ # Tests for query functionality +│ ├── __init__.py +│ ├── conftest.py # Query-specific fixtures +│ ├── test_query.py +│ └── test_saved_queries.py +└── sql/ # Tests for SQL functionality + ├── vcr_caessettes # all saved HTTP responses for testing + ├── __init__.py + ├── conftest.py # SQL-specific fixtures + ├── test_sql_connection.py + ├── test_sql_library.py + ├── test_sql_version.py + └── test_verify_cluster.py +``` + +## Test Components + +The test suite covers the following components: + +1. **Main Commands**: Tests for the main CLI commands and their behavior. + +2. **Interactive Shell**: Tests for the interactive shell functionality, including command processing, query execution, and user interface elements. + +3. **Configuration**: Tests for configuration loading, saving, and validation. + +4. **Literals**: Tests for SQL and PPL literals handling and auto-completion. + +5. **Query**: Tests for query execution, results formatting, and saved queries functionality. + +6. **SQL**: Tests for SQL connection, library management, version handling, and cluster verification. + +## Test Dependencies + +The test suite uses the following dependencies: + +1. **pytest**: The main testing framework. +2. **unittest.mock**: For mocking objects and functions during testing. +3. **Various fixtures**: Defined in `conftest.py` files to provide common test setup for each test. +4. **vcrpy**: For capturing real HTTP requests and reuse for testing + +## Running Tests + +```bash +# Change to tests directory +cd src/main/python/opensearchsql_cli/tests/ + +# Run all tests +pytest + +# Run specific test file +pytest test_main_commands.py + +# Run specific test class +pytest test_main_commands.py::TestCommands + +# Run specific test method +pytest test_main_commands.py::TestCommands::test_endpoint_command +``` + +## Warning Filters + +The test suite includes warning filters defined in `pytest.init` to suppress specific deprecation warnings that are not relevant to the test functionality. These filters help keep the test output clean and focused on actual test results. + +The following warnings are filtered: + +``` +[pytest] +filterwarnings = + ignore::DeprecationWarning:requests_aws4auth.* + ignore::DeprecationWarning:pkg_resources.* + ignore::DeprecationWarning:typer.params.* + ignore::DeprecationWarning:pyfiglet.* + ignore:pkg_resources is deprecated as an API:DeprecationWarning + ignore:The 'is_flag' and 'flag_value' parameters are not supported by Typer:DeprecationWarning + ignore:datetime.datetime.utcnow.*:DeprecationWarning + ignore:Connecting to .* using SSL with verify_certs=False is insecure:UserWarning +``` + +These filters suppress deprecation warnings from third-party libraries that are used in the project but are not directly related to the functionality being tested. + +## Writing New Tests + +When writing new tests for the OpenSearch SQL CLI, follow these guidelines: + +1. **Test Organization**: Place your tests in the appropriate subdirectory based on the component being tested. + +2. **Test Classes**: Use classes to group related tests together, following the naming convention `Test`. + +3. **Test Methods**: Name test methods descriptively, starting with `test_` prefix. + +4. **Fixtures**: Use fixtures from the appropriate `conftest.py` file to set up test dependencies. + +5. **Parameterization**: Use `@pytest.mark.parametrize` for testing multiple scenarios with the same test logic. + +6. **Documentation**: Include docstrings for test classes and methods to explain what they're testing. + +Example: + +```python +""" +Tests for Example Component. + +This module contains tests for the ExampleComponent class. +""" + +import pytest +from unittest.mock import patch, MagicMock + +from ..example_component import ExampleComponent + + +class TestExampleComponent: + """ + Test class for ExampleComponent functionality. + """ + + def test_init(self): + """Test initialization of ExampleComponent.""" + component = ExampleComponent() + assert component.attribute == expected_value + + @pytest.mark.parametrize( + "input_value, expected_output", + [ + ("value1", "result1"), + ("value2", "result2"), + ], + ) + def test_method(self, input_value, expected_output): + """Test method behavior with different inputs.""" + component = ExampleComponent() + result = component.method(input_value) + assert result == expected_output +``` + +## Mocking Strategy + +The test suite uses extensive mocking to isolate components during testing. The main mocking strategies include: + +1. **Mock Objects**: Using `MagicMock` to create mock objects that simulate the behavior of real objects. + +2. **Patching**: Using `patch` to temporarily replace classes or functions with mock objects during testing. + +3. **Fixtures**: Using pytest fixtures to provide common mock objects across tests. + +## Test Coverage + +The test suite aims to cover all critical functionality of the OpenSearch SQL CLI, including: + +- Command-line argument parsing +- Configuration management +- Connection handling +- Query execution and results formatting +- Interactive shell functionality +- Error handling + +When adding new features to the CLI, ensure that appropriate tests are added to maintain test coverage. diff --git a/src/main/python/opensearchsql_cli/tests/__init__.py b/src/main/python/opensearchsql_cli/tests/__init__.py new file mode 100644 index 0000000..aeb542e --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/__init__.py @@ -0,0 +1,5 @@ +""" +Main tests package. + +This package contains tests for the main module. +""" diff --git a/src/main/python/opensearchsql_cli/tests/config/__init__.py b/src/main/python/opensearchsql_cli/tests/config/__init__.py new file mode 100644 index 0000000..bc6c69f --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/config/__init__.py @@ -0,0 +1,5 @@ +""" +Config tests package. + +This package contains tests for the config functionality. +""" diff --git a/src/main/python/opensearchsql_cli/tests/config/conftest.py b/src/main/python/opensearchsql_cli/tests/config/conftest.py new file mode 100644 index 0000000..954513b --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/config/conftest.py @@ -0,0 +1,42 @@ +""" +Pytest fixtures for configuration tests. + +This module contains shared fixtures for testing the configuration functionality. +""" + +import yaml +import pytest +from unittest.mock import patch, mock_open + + +@pytest.fixture +def mock_config_data(): + """ + Fixture providing mock configuration data. + """ + return { + "Connection": { + "endpoint": "localhost:9200", + "username": "", + "password": "", + "insecure": False, + "aws_auth": False, + }, + "Query": { + "language": "ppl", + "format": "table", + "vertical": False, + "version": "", + }, + "SqlSettings": {"QUERY_SIZE_LIMIT": 200, "FIELD_TYPE_TOLERANCE": True}, + } + + +@pytest.fixture +def mock_config_file(mock_config_data): + """ + Fixture providing a mock for the open function when reading config file. + """ + mock_file = mock_open(read_data=yaml.dump(mock_config_data)) + with patch("builtins.open", mock_file): + yield mock_file diff --git a/src/main/python/opensearchsql_cli/tests/config/test_config.py b/src/main/python/opensearchsql_cli/tests/config/test_config.py new file mode 100644 index 0000000..931acf5 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/config/test_config.py @@ -0,0 +1,154 @@ +""" +Tests for Configuration Management. + +This module contains tests for the Configuration Management functionality. +""" + +import os +import yaml +import pytest +from unittest.mock import patch, mock_open, MagicMock +from opensearchsql_cli.config.config import Config + + +class TestConfig: + """ + Test class for Config. + """ + + @pytest.mark.parametrize("file_exists", [True, False]) + def test_load_config(self, file_exists, mock_config_data, mock_config_file): + """ + Test case 1: Test loading configuration from file. + Tests both when file exists and when it doesn't. + """ + print(f"\n=== Testing config loading (file_exists={file_exists}) ===") + + with patch("os.path.exists", return_value=file_exists): + config = Config() + + if file_exists: + # If file exists, config should be loaded + assert config.config == mock_config_data + print(f"Config loaded successfully with {len(config.config)} sections") + for section in config.config: + print(f"Section '{section}' has {len(config.config[section])} keys") + else: + # If file doesn't exist, config should be empty + assert config.config == {} + print("Config is empty as expected when file doesn't exist") + + @pytest.mark.parametrize( + "section, key, default, expected", + [ + ("Connection", "endpoint", None, "localhost:9200"), + ("Connection", "nonexistent", "default_value", "default_value"), + ("NonexistentSection", "key", "default_value", "default_value"), + ], + ) + def test_get_config_value(self, section, key, default, expected, mock_config_data): + """ + Test case 2: Test getting configuration values. + """ + print(f"\n=== Testing get config value (section={section}, key={key}) ===") + + with patch("os.path.exists", return_value=True), patch( + "builtins.open", mock_open(read_data=yaml.dump(mock_config_data)) + ): + config = Config() + + value = config.get(section, key, default) + assert value == expected + print(f"Got value '{value}' for {section}.{key} (expected: {expected})") + + @pytest.mark.parametrize( + "section, key, default, expected", + [ + ("Connection", "insecure", None, False), + ("SqlSettings", "FIELD_TYPE_TOLERANCE", None, True), + ("Query", "nonexistent", True, True), + ("Connection", "nonexistent", False, False), + ], + ) + def test_get_boolean_config_value( + self, section, key, default, expected, mock_config_data + ): + """ + Test case 3: Test getting boolean configuration values. + """ + print( + f"\n=== Testing get boolean config value (section={section}, key={key}) ===" + ) + + with patch("os.path.exists", return_value=True), patch( + "builtins.open", mock_open(read_data=yaml.dump(mock_config_data)) + ): + config = Config() + + value = config.get_boolean(section, key, default) + assert value is expected + print( + f"Got boolean value '{value}' for {section}.{key} (expected: {expected})" + ) + + def test_set_config_value(self, mock_config_data): + """ + Test case 4: Test setting configuration values. + """ + print("\n=== Testing set config value ===") + + # Create a mock for the open function for both reading and writing + mock_file = mock_open(read_data=yaml.dump(mock_config_data)) + + with patch("os.path.exists", return_value=True), patch( + "builtins.open", mock_file + ): + config = Config() + + # Test setting a value in an existing section + result = config.set("Connection", "endpoint", "new-endpoint:9200") + assert result is True + assert config.config["Connection"]["endpoint"] == "new-endpoint:9200" + print("Successfully set value in existing section") + + # Test setting a value in a new section + result = config.set("NewSection", "new_key", "new_value") + assert result is True + assert config.config["NewSection"]["new_key"] == "new_value" + print("Successfully set value in new section") + + # Verify the file was written to + mock_file.assert_called_with(config.config_file, "w") + print("Config file was written to") + + def test_display_config(self, mock_config_data): + """ + Test case 5: Test displaying configuration. + """ + print("\n=== Testing display config ===") + + with patch("os.path.exists", return_value=True), patch( + "builtins.open", mock_open(read_data=yaml.dump(mock_config_data)) + ), patch("opensearchsql_cli.config.config.console") as mock_console: + config = Config() + + # Call display method + config.display() + + # Verify console.print was called + assert mock_console.print.call_count > 0 + print(f"Console.print was called {mock_console.print.call_count} times") + + # Check that password masking works + config.config["Connection"]["password"] = "secret" + config.display() + + # Find the call that would display the password + password_masked = False + for call in mock_console.print.call_args_list: + if "********" in str(call): + password_masked = True + break + + assert password_masked + print("Password was properly masked in display") diff --git a/src/main/python/opensearchsql_cli/tests/conftest.py b/src/main/python/opensearchsql_cli/tests/conftest.py new file mode 100644 index 0000000..fddcd83 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/conftest.py @@ -0,0 +1,124 @@ +""" +Pytest configuration file for opensearchsql-cli main tests. + +This file contains fixtures and configuration for pytest tests. +""" + +import os +import sys +import pytest +from unittest.mock import MagicMock, patch + + +@pytest.fixture +def mock_sql_connection(): + """ + Fixture that returns a mock SQL connection. + """ + mock = MagicMock() + mock.connect.return_value = True + mock.verify_opensearch_connection.return_value = True + mock.initialize_sql_library.return_value = True + mock.version = "2.0.0" + mock.url = "http://test:9200" + mock.username = "admin" + return mock + + +@pytest.fixture +def mock_sql_library_manager(): + """ + Fixture that returns a mock SQL library manager. + """ + mock = MagicMock() + mock.started = False + return mock + + +@pytest.fixture +def mock_sql_version(): + """ + Fixture that returns a mock SQL version manager. + """ + mock = MagicMock() + mock.version = "1.0.0" + mock.set_version.return_value = True + return mock + + +@pytest.fixture +def mock_config_manager(): + """ + Fixture that returns a mock config manager. + """ + mock = MagicMock() + + # Mock the get method + def mock_get(section, key, default): + config_values = { + ("Query", "language", "ppl"): "ppl", + ("Query", "format", "table"): "table", + ("Connection", "endpoint", ""): "", + ("Connection", "username", ""): "", + ("Connection", "password", ""): "", + } + return config_values.get((section, key, default), default) + + mock.get.side_effect = mock_get + + # Mock the get_boolean method + def mock_get_boolean(section, key, default): + boolean_values = { + ("Connection", "insecure", False): False, + ("Connection", "aws_auth", False): False, + ("Query", "vertical", False): False, + } + return boolean_values.get((section, key, default), default) + + mock.get_boolean.side_effect = mock_get_boolean + + return mock + + +@pytest.fixture +def mock_console(): + """ + Fixture that returns a mock console. + """ + mock = MagicMock() + return mock + + +@pytest.fixture +def mock_figlet(): + """ + Fixture that returns a mock figlet. + """ + mock = MagicMock() + mock.return_value = "OpenSearch" + return mock + + +@pytest.fixture +def mock_saved_queries(): + """ + Fixture that returns a mock SavedQueries instance. + """ + mock = MagicMock() + # Configure the loading_query mock to return expected values for tests + mock.loading_query.return_value = (True, "select * from test", "result", "SQL") + return mock + + +@pytest.fixture +def interactive_shell(mock_sql_connection, mock_saved_queries): + """ + Fixture that returns an InteractiveShell instance with mocked dependencies. + """ + from ..interactive_shell import InteractiveShell + + with patch("os.path.exists", return_value=True), patch( + "builtins.open", MagicMock() + ): + shell = InteractiveShell(mock_sql_connection, mock_saved_queries) + return shell diff --git a/src/main/python/opensearchsql_cli/tests/literals/__init__.py b/src/main/python/opensearchsql_cli/tests/literals/__init__.py new file mode 100644 index 0000000..2ba3286 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/literals/__init__.py @@ -0,0 +1,5 @@ +""" +Literals tests package. + +This package contains tests for the literals functionality. +""" diff --git a/src/main/python/opensearchsql_cli/tests/literals/conftest.py b/src/main/python/opensearchsql_cli/tests/literals/conftest.py new file mode 100644 index 0000000..bb82f8b --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/literals/conftest.py @@ -0,0 +1,59 @@ +""" +Pytest fixtures for literals tests. + +This module contains shared fixtures for testing the literals functionality. +""" + +import os +import json +import pytest +from unittest.mock import patch, mock_open + + +@pytest.fixture +def mock_ppl_literals_data(): + """ + Fixture providing mock PPL literals data. + """ + return { + "keywords": ["source", "where", "fields"], + "functions": ["count", "sum", "avg"], + } + + +@pytest.fixture +def mock_sql_literals_data(): + """ + Fixture providing mock SQL literals data. + """ + return { + "keywords": ["SELECT", "FROM", "WHERE"], + "functions": ["COUNT", "SUM", "AVG"], + } + + +@pytest.fixture +def mock_literals_file(request): + """ + Fixture providing a mock for the open function when reading literals files. + + Args: + request: The pytest request object with a 'param' attribute specifying + which literals to use ('ppl' or 'sql') + """ + language = request.param if hasattr(request, "param") else "sql" + + if language.lower() == "ppl": + mock_data = { + "keywords": ["source", "where", "fields"], + "functions": ["count", "sum", "avg"], + } + else: # sql + mock_data = { + "keywords": ["SELECT", "FROM", "WHERE"], + "functions": ["COUNT", "SUM", "AVG"], + } + + mock_file = mock_open(read_data=json.dumps(mock_data)) + with patch("builtins.open", mock_file): + yield mock_file diff --git a/src/main/python/opensearchsql_cli/tests/literals/test_literals.py b/src/main/python/opensearchsql_cli/tests/literals/test_literals.py new file mode 100644 index 0000000..8cc1b04 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/literals/test_literals.py @@ -0,0 +1,153 @@ +""" +Tests for OpenSearch Literals. + +This module contains tests for the OpenSearch Literals functionality. +""" + +import os +import json +import pytest +from unittest.mock import patch, mock_open +from rich.text import Text +from opensearchsql_cli.literals.opensearch_literals import Literals + + +class TestLiterals: + """ + Test class for OpenSearch Literals. + """ + + @pytest.mark.parametrize( + "language, expected_keyword, expected_function, mock_data", + [ + ( + "ppl", + "source", + "count", + { + "keywords": ["source", "where", "fields"], + "functions": ["count", "sum", "avg"], + }, + ), + ( + "sql", + "FROM", + "COUNT", + { + "keywords": ["FROM", "SELECT", "WHERE"], + "functions": ["COUNT", "SUM", "AVG"], + }, + ), + ], + ) + def test_get_literals_loads_json_files( + self, + language, + expected_keyword, + expected_function, + mock_data, + ): + """ + Test case 1: Verify that the JSON files for PPL and SQL are loaded correctly. + Uses parametrize to test both PPL and SQL literals. + """ + print(f"\n=== Testing {language.upper()} literals loading ===") + + # Mock the file opening and reading + with patch( + "os.path.join", + return_value=f"mock_path/opensearch_literals_{language}.json", + ), patch("builtins.open", mock_open(read_data=json.dumps(mock_data))): + + # Load literals for the specified language + print(f"Loading {language.upper()} literals...") + literals = Literals.get_literals(language=language) + + # Verify the structure + assert isinstance(literals, dict) + assert "keywords" in literals + assert "functions" in literals + assert isinstance(literals["keywords"], list) + assert isinstance(literals["functions"], list) + + # Verify expected keywords and functions + assert expected_keyword in literals["keywords"] + assert expected_function in literals["functions"] + + # Print information + print( + f"{language.upper()} literals loaded successfully: {len(literals['keywords'])} keywords, {len(literals['functions'])} functions" + ) + print( + f"Sample {language.upper()} keywords: {', '.join(literals['keywords'][:5])}" + ) + print( + f"Sample {language.upper()} functions: {', '.join(literals['functions'][:5])}" + ) + + @pytest.mark.parametrize( + "language, keyword, function", + [("SQL", "FROM", "COUNT"), ("PPL", "source", "count")], + ) + def test_colorize_keywords_and_functions( + self, + language, + keyword, + function, + mock_sql_literals_data, + mock_ppl_literals_data, + ): + """ + Test case 2: Verify that keywords are colorized as bold green and functions as green. + Uses parametrize to test both SQL and PPL keywords and functions. + """ + print(f"\n=== Testing {language} keyword and function colorization ===") + + # Use the mock literals data from fixtures + mock_literals = ( + mock_ppl_literals_data if language == "PPL" else mock_sql_literals_data + ) + + print( + f"Mock {language} literals created with keywords: {', '.join(mock_literals['keywords'])}" + ) + print( + f"Mock {language} literals created with functions: {', '.join(mock_literals['functions'])}" + ) + + print(f"\nTesting {language} keyword colorization: bold green") + keyword_text = Literals.colorize_keywords(keyword, mock_literals) + assert isinstance(keyword_text, Text) + assert keyword_text.style == "bold green" + print(f"Keyword '{keyword}' colorized with style: {keyword_text.style}") + + print(f"\nTesting {language} function colorization: green") + function_text = Literals.colorize_keywords(function, mock_literals) + assert isinstance(function_text, Text) + assert function_text.style == "green" + print(f"Function '{function}' colorized with style: {function_text.style}") + + print(f"\nTesting {language} case-insensitivity for keywords") + keyword_text_lower = Literals.colorize_keywords(keyword.lower(), mock_literals) + assert isinstance(keyword_text_lower, Text) + assert keyword_text_lower.style == "bold green" + print( + f"Lowercase keyword '{keyword.lower()}' colorized with style: {keyword_text_lower.style}" + ) + + print(f"\nTesting {language} case-insensitivity for functions") + function_text_lower = Literals.colorize_keywords( + function.lower(), mock_literals + ) + assert isinstance(function_text_lower, Text) + assert function_text_lower.style == "green" + print( + f"Lowercase function '{function.lower()}' colorized with style: {function_text_lower.style}" + ) + + print(f"\nTesting {language} non-keyword, non-function text: plain text") + plain_text = Literals.colorize_keywords("table_name", mock_literals) + assert isinstance(plain_text, str) + print( + f"Non-keyword/function 'table_name' remains as type: {type(plain_text).__name__}" + ) diff --git a/src/main/python/opensearchsql_cli/tests/pytest.init b/src/main/python/opensearchsql_cli/tests/pytest.init new file mode 100644 index 0000000..bfd2403 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/pytest.init @@ -0,0 +1,10 @@ +[pytest] +filterwarnings = + ignore::DeprecationWarning:requests_aws4auth.* + ignore::DeprecationWarning:pkg_resources.* + ignore::DeprecationWarning:typer.params.* + ignore::DeprecationWarning:pyfiglet.* + ignore:pkg_resources is deprecated as an API:DeprecationWarning + ignore:The 'is_flag' and 'flag_value' parameters are not supported by Typer:DeprecationWarning + ignore:datetime.datetime.utcnow.*:DeprecationWarning + ignore:Connecting to .* using SSL with verify_certs=False is insecure:UserWarning diff --git a/src/main/python/opensearchsql_cli/tests/query/__init__.py b/src/main/python/opensearchsql_cli/tests/query/__init__.py new file mode 100644 index 0000000..2107412 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/query/__init__.py @@ -0,0 +1,5 @@ +""" +Query tests package. + +This package contains tests for the query functionality. +""" diff --git a/src/main/python/opensearchsql_cli/tests/query/conftest.py b/src/main/python/opensearchsql_cli/tests/query/conftest.py new file mode 100644 index 0000000..4078b6e --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/query/conftest.py @@ -0,0 +1,211 @@ +""" +Pytest configuration file for opensearchsql-cli query tests. + +This file contains fixtures and configuration for pytest tests. +""" + +import os +import sys +import json +import pytest +import tempfile +import warnings +from unittest.mock import MagicMock, patch + + +# Fixtures for query execution +@pytest.fixture +def mock_csv_response(): + """ + Fixture that returns a mock CSV response. + """ + return "name,hire_date,department,age\nTest,1999-01-01 00:00:00,Engineering,20" + + +@pytest.fixture +def mock_json_response(): + """ + Fixture that returns a mock JSON/table response. + This can be used for both JSON format and table format (horizontal/vertical). + """ + return """ + { + "schema": [ + { + "name": "name", + "type": "string" + }, + { + "name": "hire_date", + "type": "timestamp" + }, + { + "name": "department", + "type": "string" + }, + { + "name": "age", + "type": "integer" + } + ], + "datarows": [ + [ + "Test", + "1999-01-01 00:00:00", + "Engineering", + 20 + ] + ], + "total": 1, + "size": 1 + } + """ + + +@pytest.fixture +def mock_table_response(): + """ + Fixture that returns a mock table response. + This is the formatted horizontal table output for display. + """ + return """ +Fetched 1 rows with a total of 1 hits +┏━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━┓ +┃ name ┃ hire_date ┃ department ┃ age ┃ +┡━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━┩ +│ Test │ 1999-01-01 00:00:00 │ Engineering │ 20 │ +├───────┼─────────────────────┼─────────────┼─────┤ +""" + + +@pytest.fixture +def mock_vertical_response(): + """ + Fixture that returns a mock vertical table response. + This is the formatted vertical table output for display. + """ + return """ +Fetched 1 rows with a total of 1 hits + RECORD 1 +┌────────────┬─────────────────────┐ +│ name │ Test │ +├────────────┼─────────────────────┤ +│ hire_date │ 1999-01-01 00:00:00 │ +├────────────┼─────────────────────┤ +│ department │ Engineering │ +├────────────┼─────────────────────┤ +│ age │ 20 │ +└────────────┴─────────────────────┘ +""" + + +@pytest.fixture +def mock_calcite_explain(): + """ + Fixture that returns a mock Calcite explain plan. + """ + return r""" +{ +"calcite": { +"logical": "LogicalProject(name=[$0], hire_date=[$1], department=[$2], age=[$3])\n CalciteLogicalIndexScan(table=[[OpenSearch, employees]])\n", +"physical": "CalciteEnumerableIndexScan(table=[[OpenSearch, employees]], PushDownContext=[[PROJECT->[name, hire_date, department, age]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"name\",\"hire_date\",\"department\",\"age\"],\"excludes\":[]}}, requestedTotalSize=99999, pageSize=null, startFrom=0)])\n" +} +} + """ + + +@pytest.fixture +def mock_legacy_explain(): + """ + Fixture that returns a mock Calcite explain plan. + """ + return r"""{ +"root": { +"name": "ProjectOperator", +"description": { +"fields": "[name, hire_date, department, age]" +}, +"children": [ +{ +"name": "OpenSearchIndexScan", +"description": { +"request": "OpenSearchQueryRequest(indexName=employees, sourceBuilder={\"from\":0,\"size\":10000,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"name\",\"hire_date\",\"department\",\"age\"],\"excludes\":[]}}, needClean=true, searchDone=false, pitId=s9y3QQEJZW1wbG95ZWVzFmNsWWhicUdrVFBXUXRpM1FKdHBrSVEAFmpMNjJzN3QzUjR1QzB1NURUNDAwUHcAAAAAAAAAAAcWVi1KUEZNdDJTQ0dIMjlXbDhrUDl6UQEWY2xZaGJxR2tUUFdRdGkzUUp0cGtJUQAA, cursorKeepAlive=1m, searchAfter=null, searchResponse=null)" +}, +"children": [] +} +] +} +}""" + + +@pytest.fixture +def mock_syntax_error_response(): + """ + Fixture that returns a mock syntax error response. + """ + return "Invalid query: queryExecution Error: org.opensearch.sql.common.antlr.SyntaxCheckException:" + + +@pytest.fixture +def mock_semantic_error_response(): + """ + Fixture that returns a mock semantic error response. + """ + return "Invalid query: queryExecution Error: org.opensearch.sql.common.antlr.SyntaxCheckException:" + + +@pytest.fixture +def mock_index_not_found_response(): + """ + Fixture that returns a mock index not found error response. + """ + return "Invalid query: queryExecution Error: java.lang.RuntimeException: [a] OpenSearchStatusException[OpenSearch exception [type=index_not_found_exception, reason=no such index [a]]]" + + +@pytest.fixture +def mock_null_statement_response(): + """ + Fixture that returns a mock null statement error response. + """ + return 'Invalid query: queryExecution Execution Error: java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "statement" is null' + + +# Fixtures for saved queries tests +@pytest.fixture +def temp_dir(): + """ + Create a temporary directory for saved queries. + """ + with tempfile.TemporaryDirectory() as temp_dir: + yield temp_dir + + +@pytest.fixture +def saved_queries(temp_dir): + """ + Create a SavedQueries instance with a temporary directory. + """ + from opensearchsql_cli.query.saved_queries import SavedQueries + + return SavedQueries(base_dir=temp_dir) + + +@pytest.fixture +def mock_console(): + """ + Mock the console.print method. + """ + with patch("opensearchsql_cli.query.saved_queries.console") as mock_console: + yield mock_console + + +@pytest.fixture +def mock_connection(): + """ + Mock the SQL connection. + """ + mock_connection = MagicMock() + mock_connection.query_executor.return_value = ( + '{"schema":[{"name":"test"}],"datarows":[["value"]]}' + ) + return mock_connection diff --git a/src/main/python/opensearchsql_cli/tests/query/test_query.py b/src/main/python/opensearchsql_cli/tests/query/test_query.py new file mode 100644 index 0000000..98d1fec --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/query/test_query.py @@ -0,0 +1,394 @@ +""" +Tests for the execute_query module. + +This module contains tests for the query execution functionality. +""" + +import pytest +import json +from rich.console import Console +from unittest.mock import patch, MagicMock +from opensearchsql_cli.query.execute_query import ExecuteQuery +from opensearchsql_cli.query.query_results import QueryResults +from opensearchsql_cli.query.explain_results import ExplainResults + +# Create a console instance for printing +console = Console() + + +class TestQuery: + """ + Test class for ExecuteQuery methods. + """ + + def execute_query_test( + self, + mock_console, + mock_response, + test_case_num, + test_case_name, + query, + is_ppl_mode=True, + is_explain=False, + format="table", + is_vertical=False, + expected_success=True, + ): + """ + Dynamic function to execute query tests with different parameters. + + Args: + mock_console: Mocked console object + mock_response: Mock response to be returned by the query executor + test_case_num: Test case number for display + test_case_name: Test case name for display + query: Query to execute + is_ppl_mode: Whether to use PPL mode (True) or SQL mode (False) + is_explain: Whether to use explain mode + format: Output format (table, csv, json) + is_vertical: Whether to use vertical table format + expected_success: Whether the query is expected to succeed + """ + # Arrange + mock_connection = MagicMock() + mock_print = MagicMock() + + # Mock the query_executor to return the provided response + mock_connection.query_executor.return_value = mock_response + + # Print the test case header and details + print(f"\n\n=== Test Case {test_case_num}: {test_case_name} ===") + print(f"Query: {query}") + print(f"Mode: {'PPL' if is_ppl_mode else 'SQL'}") + print(f"Format: {format.upper()}") + print(f"Vertical: {is_vertical}") + print(f"Explain: {is_explain}") + print(f"Expected Success: {expected_success}") + + # Act + success, result, formatted_result = ExecuteQuery.execute_query( + connection=mock_connection, + query=query, + is_ppl_mode=is_ppl_mode, + is_explain=is_explain, + format=format, + is_vertical=is_vertical, + print_function=mock_print, + ) + + # Display the result based on format and response type + print("\nResult:") + if format == "table": + table_data = QueryResults.table_format(mock_response, is_vertical) + QueryResults.display_table_result(table_data, console.print) + elif format == "json" and is_explain: + if "calcite" in mock_response: + formatted_explain = ExplainResults.explain_calcite(mock_response) + print(formatted_explain) + elif "root" in mock_response: + formatted_explain = ExplainResults.explain_legacy(mock_response) + print(formatted_explain) + else: + print(mock_response) + else: + print(mock_response) + + # Assert + assert success is expected_success + assert result == mock_response + print(f"\nSuccess: {success} (Expected: {expected_success})") + + # Verify the connection was called with the correct parameters + mock_connection.query_executor.assert_called_once_with( + query, is_ppl_mode, is_explain, format + ) + print( + f"Query Executor Called: query={query}, is_ppl_mode={is_ppl_mode}, is_explain={is_explain}, format={format}" + ) + + # Verify the print function was called with the expected arguments + if expected_success: + # Check that the Result: message was printed + mock_print.assert_any_call("Result:\n") + print("Result message printed") + else: + # Check for error messages based on the response content + if "SyntaxCheckException" in mock_response: + error_parts = mock_response.split("SyntaxCheckException:", 1) + mock_print.assert_any_call( + f"[bold red]Syntax Error: [/bold red][red]{error_parts[1].strip()}[/red]\n" + ) + print(f"Syntax Error: {error_parts[1].strip()}") + elif "SemanticCheckException" in mock_response: + error_parts = mock_response.split("SemanticCheckException:", 1) + mock_print.assert_any_call( + f"[bold red]Semantic Error: [/bold red][red]{error_parts[1].strip()}[/red]\n" + ) + print(f"Semantic Error: {error_parts[1].strip()}") + elif "index_not_found_exception" in mock_response: + mock_print.assert_any_call("[bold red]Index does not exist[/bold red]") + print("Index does not exist") + elif '"statement" is null' in mock_response: + mock_print.assert_any_call( + "[bold red]Error: [/bold red][red]Could not parse the query. Please check the syntax and try again.[/red]" + ) + print("Statement is null") + + # Verify all calls to mock_print + assert mock_print.call_count >= 1 + print(f"Print function called {mock_print.call_count} times") + print("=" * 50) + + # Comprehensive parameterized test covering all test cases + @pytest.mark.parametrize( + "test_case_num, test_case_name, query, is_ppl_mode, is_explain, format, is_vertical, expected_success, fixture_name", + [ + # PPL test + ( + 1, + "PPL Table", + "source=employees", + True, + False, + "table", + False, + True, + "mock_json_response", + ), + ( + 2, + "PPL Vertical Table", + "source=employees", + True, + False, + "table", + True, + True, + "mock_json_response", + ), + ( + 3, + "PPL CSV", + "source=employees", + True, + False, + "csv", + False, + True, + "mock_csv_response", + ), + ( + 4, + "PPL JSON", + "source=employees", + True, + False, + "json", + False, + True, + "mock_json_response", + ), + ( + 5, + "PPL Explain Calcite", + "explain source=employees", + True, + True, + "json", + False, + True, + "mock_calcite_explain", + ), + ( + 6, + "PPL Syntax Error", + "invalid", + True, + False, + "json", + False, + False, + "mock_syntax_error_response", + ), + ( + 7, + "PPL Semantic Error", + "source=employees | fields unknown_field", + True, + False, + "json", + False, + False, + "mock_semantic_error_response", + ), + ( + 8, + "PPL Index Not Found Error", + "source=nonexistent_index", + True, + False, + "json", + False, + False, + "mock_index_not_found_response", + ), + ( + 9, + "PPL Null Statement Error", + ";", + True, + False, + "json", + False, + False, + "mock_null_statement_response", + ), + # SQL test + ( + 10, + "SQL Table", + "SELECT * FROM employees", + False, + False, + "table", + False, + True, + "mock_json_response", + ), + ( + 11, + "SQL Vertical Table", + "SELECT * FROM employees", + False, + False, + "table", + True, + True, + "mock_json_response", + ), + ( + 12, + "SQL CSV", + "SELECT * FROM employees", + False, + False, + "csv", + False, + True, + "mock_csv_response", + ), + ( + 13, + "SQL JSON", + "SELECT * FROM employees", + False, + False, + "json", + False, + True, + "mock_json_response", + ), + ( + 14, + "SQL Explain Legacy", + "EXPLAIN SELECT * FROM employees", + False, + True, + "json", + False, + True, + "mock_legacy_explain", + ), + ( + 15, + "SQL Syntax Error", + "SELECT * FROMM employees", + False, + False, + "json", + False, + False, + "mock_syntax_error_response", + ), + ( + 16, + "SQL Semantic Error", + "SELECT unknown_field FROM employees", + False, + False, + "json", + False, + False, + "mock_semantic_error_response", + ), + ( + 17, + "SQL Index Not Found Error", + "SELECT * FROM nonexistent_index", + False, + False, + "json", + False, + False, + "mock_index_not_found_response", + ), + ( + 18, + "SQL Null Statement Error", + ";", + False, + False, + "json", + False, + False, + "mock_null_statement_response", + ), + ], + ) + @patch("opensearchsql_cli.query.execute_query.console") + def test_query_parameterized( + self, + mock_console, + test_case_num, + test_case_name, + query, + is_ppl_mode, + is_explain, + format, + is_vertical, + expected_success, + fixture_name, + request, + ): + """ + Parameterized test for executing queries with different configurations. + + Args: + mock_console: Mocked console object + test_case_num: Test case number for display + test_case_name: Test case name for display + query: Query to execute + is_ppl_mode: Whether to use PPL mode (True) or SQL mode (False) + is_explain: Whether to use explain mode + format: Output format (table, csv, json) + is_vertical: Whether to use vertical table format + expected_success: Whether the query is expected to succeed + fixture_name: Name of the fixture to use for mock response + request: pytest request fixture for accessing other fixtures + """ + # Get the mock response from the fixture + mock_response = request.getfixturevalue(fixture_name) + + # Execute the test + self.execute_query_test( + mock_console=mock_console, + mock_response=mock_response, + test_case_num=test_case_num, + test_case_name=test_case_name, + query=query, + is_ppl_mode=is_ppl_mode, + is_explain=is_explain, + format=format, + is_vertical=is_vertical, + expected_success=expected_success, + ) diff --git a/src/main/python/opensearchsql_cli/tests/query/test_saved_queries.py b/src/main/python/opensearchsql_cli/tests/query/test_saved_queries.py new file mode 100644 index 0000000..6b92dea --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/query/test_saved_queries.py @@ -0,0 +1,680 @@ +""" +Tests for saved queries functionality. + +This module contains tests for the saved query commands (-s --save, -s --load, -s --remove, -s --list). +""" + +import pytest +import os +import json +from opensearchsql_cli.query.saved_queries import SavedQueries +from unittest.mock import patch, MagicMock + + +class TestSavedQueries: + """ + Test class for saved queries functionality. + """ + + # Test case counter + test_case_num = 0 + + def print_test_info(self, description, result=None): + """ + Print test case number, description, and result. + + Args: + description: Test case description + result: Test result (optional) + """ + + if result is None: + TestSavedQueries.test_case_num += 1 + print( + f"\n=== Test Case #{TestSavedQueries.test_case_num}: {description} ===" + ) + else: + print(f"Result: {result}") + + @pytest.mark.parametrize( + "test_id, description, expected_result", + [ + (1, "Initialize SavedQueries", "Directory and file created successfully"), + ], + ) + def test_init_creates_directory_and_file( + self, test_id, description, expected_result, temp_dir + ): + """ + Test that the SavedQueries constructor creates the directory and file if they don't exist. + """ + self.print_test_info(f"{description} (Test #{test_id})") + + # Create a SavedQueries instance + saved_queries = SavedQueries(base_dir=temp_dir) + + # Check that the directory and file were created + assert os.path.exists(temp_dir) + assert os.path.exists(os.path.join(temp_dir, "saved.txt")) + + # Check that the file contains an empty dictionary + with open(os.path.join(temp_dir, "saved.txt"), "r") as f: + data = json.load(f) + assert data == {} + + self.print_test_info(f"{description} (Test #{test_id})", expected_result) + + @pytest.mark.parametrize( + "test_id, description, query_name, query, language, expected_success, expected_result", + [ + ( + 1, + "Save Query - Success", + "test_query", + "source=test", + "PPL", + True, + "Query saved successfully", + ), + ( + 2, + "Save Query - Already Exists", + "test_query", + "source=test2", + "PPL", + False, + "Query not saved as expected", + ), + ( + 3, + "Replace Query - Success", + "test_query", + "source=test2", + "PPL", + True, + "Query replaced successfully", + ), + ( + 4, + "Replace Query - Not Exists", + "nonexistent_query", + "source=test", + "PPL", + False, + "Query not replaced as expected", + ), + ], + ) + def test_save_commands( + self, + test_id, + description, + query_name, + query, + language, + expected_success, + expected_result, + saved_queries, + mock_console, + ): + """ + Test saving and replacing queries. + """ + self.print_test_info(f"{description} (Test #{test_id})") + + # For "Already Exists" test, save a query first + if "Already Exists" in description: + saved_queries.save_query(query_name, "source=test", "PPL") + + # Try to save another query with the same name + result = saved_queries.save_query(query_name, query, language) + + # Check that the query was not saved + assert result is expected_success + mock_console.print.assert_any_call( + f"A query with name '[green]{query_name}[/green]' already exists." + ) + + # For "Replace" tests + elif "Replace" in description: + # For "Replace - Success", save a query first + if expected_success: + saved_queries.save_query(query_name, "source=test", "PPL") + + # Replace the query + result = saved_queries.replace_query(query_name, query, language) + + # Check that the query was replaced or not based on expected_success + assert result is expected_success + + if expected_success: + mock_console.print.assert_called_with( + f"Query '[green]{query_name}[/green]' replaced" + ) + + # Check that the query was updated in the file + saved_data = saved_queries._load_saved_data() + assert query_name in saved_data + assert saved_data[query_name]["query"] == query + else: + mock_console.print.assert_called_with( + f"[bold red]ERROR:[/bold red] [red]No query named[/red] '[white]{query_name}[/white]' [red]exists.[/red]" + ) + + # For regular "Save" test + else: + # Save a query + result = saved_queries.save_query(query_name, query, language) + + # Check that the query was saved + assert result is expected_success + mock_console.print.assert_called_with( + f"Query saved as '[green]{query_name}[/green]'" + ) + + # Check that the query was saved to the file + saved_data = saved_queries._load_saved_data() + assert query_name in saved_data + assert saved_data[query_name]["query"] == query + assert saved_data[query_name]["language"] == language + assert "timestamp" in saved_data[query_name] + + self.print_test_info(f"{description} (Test #{test_id})", expected_result) + + @pytest.mark.parametrize( + "test_id, description, query_name, query, language, expected_success, expected_result", + [ + ( + 1, + "Load Query - Success", + "test_query", + "source=test", + "PPL", + True, + "Query loaded successfully", + ), + ( + 2, + "Load Query - Not Exists", + "nonexistent_query", + None, + None, + False, + "Query not loaded as expected", + ), + ], + ) + def test_load_commands( + self, + test_id, + description, + query_name, + query, + language, + expected_success, + expected_result, + saved_queries, + mock_console, + ): + """ + Test loading saved queries. + """ + self.print_test_info(f"{description} (Test #{test_id})") + + # For "Success" test, save a query first + if expected_success: + saved_queries.save_query(query_name, query, language) + + # Load the query + success, query_data = saved_queries.load_query(query_name) + + # Check that the query was loaded or not based on expected_success + assert success is expected_success + + if expected_success: + assert query_data["query"] == query + assert query_data["language"] == language + else: + assert query_data is None + mock_console.print.assert_called_with( + f"[bold red]ERROR:[/bold red] Saved Query '[green]{query_name}[/green]' does not exist." + ) + + self.print_test_info(f"{description} (Test #{test_id})", expected_result) + + @pytest.mark.parametrize( + "test_id, description, query_name, query, language, confirm, expected_success, expected_result", + [ + ( + 1, + "Remove Query - Success", + "test_query", + "source=test", + "PPL", + None, + True, + "Query removed successfully", + ), + ( + 2, + "Remove Query - Not Exists", + "nonexistent_query", + None, + None, + None, + False, + "Query not removed as expected", + ), + ( + 3, + "Removing Query - Confirm", + "test_query", + "source=test", + "PPL", + "y", + True, + "Query removed after confirmation", + ), + ( + 4, + "Removing Query - No Confirm", + "test_query", + "source=test", + "PPL", + "n", + False, + "Query not removed after declining", + ), + ( + 5, + "Removing Query - Not Exists", + "nonexistent_query", + None, + None, + None, + False, + "Query not found error displayed correctly", + ), + ], + ) + def test_remove_commands( + self, + test_id, + description, + query_name, + query, + language, + confirm, + expected_success, + expected_result, + saved_queries, + mock_console, + ): + """ + Test removing saved queries. + """ + self.print_test_info(f"{description} (Test #{test_id})") + + # For tests that need a query to exist first + if query and "Not Exists" not in description: + saved_queries.save_query(query_name, query, language) + + # For tests with confirmation + if "Confirm" in description: + with patch("builtins.input", return_value=confirm): + # Remove the query with confirmation + result = saved_queries.removing_query(query_name) + + # Check that the query was removed or not based on expected_success + assert result is expected_success + + if expected_success: + mock_console.print.assert_any_call( + f"Query '[green]{query_name}[/green]' removed" + ) + + # Check that the query was removed from the file + saved_data = saved_queries._load_saved_data() + assert query_name not in saved_data + else: + mock_console.print.assert_any_call( + f"Query '[green]{query_name}[/green]' was [red]NOT[/red] removed." + ) + + # Check that the query was not removed from the file + if query: + saved_data = saved_queries._load_saved_data() + assert query_name in saved_data + + # For regular "Remove" tests + elif "Not Exists" in description: + if "removing_query" in description.lower(): + # Try to remove a query that doesn't exist using removing_query + result = saved_queries.removing_query(query_name) + + # Check that the query was not removed + assert result is expected_success + mock_console.print.assert_called_with( + f"[bold red]ERROR:[/bold red] [red]Query[/red] '[green]{query_name}[/green]' [red]not found.[/red]" + ) + else: + # Try to remove a query that doesn't exist using remove_query + result = saved_queries.remove_query(query_name) + + # Check that the query was not removed + assert result is expected_success + mock_console.print.assert_called_with( + f"[bold red]ERROR:[/bold red] Saved Query '[green]{query_name}[/green]' does not exist." + ) + else: + # Remove the query + result = saved_queries.remove_query(query_name) + + # Check that the query was removed + assert result is expected_success + mock_console.print.assert_called_with( + f"Query '[green]{query_name}[/green]' removed" + ) + + # Check that the query was removed from the file + saved_data = saved_queries._load_saved_data() + assert query_name not in saved_data + + self.print_test_info(f"{description} (Test #{test_id})", expected_result) + + @pytest.mark.parametrize( + "test_id, description, queries, expected_success, expected_result", + [ + ( + 1, + "List Queries - Multiple", + [ + ("test_query1", "source=test1", "PPL"), + ("test_query2", "source=test2", "PPL"), + ], + True, + "Queries listed successfully", + ), + ( + 2, + "List Saved Queries - Formatted", + [ + ("test_query1", "source=test1", "PPL"), + ("test_query2", "source=test2", "PPL"), + ], + True, + "Queries listed with formatting successfully", + ), + ( + 3, + "List Saved Queries - Empty", + [], + False, + "Empty list handled correctly", + ), + ], + ) + def test_list_commands( + self, + test_id, + description, + queries, + expected_success, + expected_result, + saved_queries, + mock_console, + ): + """ + Test listing saved queries. + """ + self.print_test_info(f"{description} (Test #{test_id})") + + # Save queries if needed + for query_name, query, language in queries: + saved_queries.save_query(query_name, query, language) + + if "Formatted" in description: + # List the queries with formatted output + result = saved_queries.list_saved_queries() + + # Check that the queries were listed + assert result is expected_success + + if expected_success: + for query_name, query, _ in queries: + mock_console.print.assert_any_call( + f"\n- [green]{query_name}[/green]" + ) + mock_console.print.assert_any_call(f"\t[yellow]{query}[/yellow]") + else: + mock_console.print.assert_called_with( + "[yellow]No saved queries found.[/yellow]" + ) + + elif "Empty" in description: + # List the queries when there are none + result = saved_queries.list_saved_queries() + + # Check that no queries were listed + assert result is expected_success + mock_console.print.assert_called_with( + "[yellow]No saved queries found.[/yellow]" + ) + + else: + # List the queries + saved_data = saved_queries.list_queries() + + # Check that the queries are listed + for query_name, query, language in queries: + assert query_name in saved_data + assert saved_data[query_name]["query"] == query + + self.print_test_info(f"{description} (Test #{test_id})", expected_result) + + @pytest.mark.parametrize( + "test_id, description, query_name, query, language, confirm, expected_success, expected_result", + [ + ( + 1, + "Saving Query - Replace Confirmed", + "test_query", + "source=test2", + "PPL", + "y", + True, + "Query replaced after confirmation", + ), + ( + 2, + "Saving Query - Replace Declined", + "test_query", + "source=test2", + "PPL", + "n", + False, + "Query not replaced after declining", + ), + ( + 3, + "Saving Query - No Query", + "test_query", + None, + "PPL", + None, + False, + "Error message displayed correctly", + ), + ], + ) + def test_saving_query_commands( + self, + test_id, + description, + query_name, + query, + language, + confirm, + expected_success, + expected_result, + saved_queries, + mock_console, + ): + """ + Test saving queries with confirmation. + """ + self.print_test_info(f"{description} (Test #{test_id})") + + # For tests that need a query to exist first + if "No Query" not in description: + saved_queries.save_query(query_name, "source=test", "PPL") + + # For tests with confirmation + if confirm: + with patch("builtins.input", return_value=confirm): + # Save another query with the same name + result = saved_queries.saving_query(query_name, query, language) + + # Check that the query was replaced or not based on expected_success + assert result is expected_success + + if expected_success: + mock_console.print.assert_any_call( + f"Query '[green]{query_name}[/green]' replaced" + ) + + # Check that the query was updated in the file + saved_data = saved_queries._load_saved_data() + assert query_name in saved_data + assert saved_data[query_name]["query"] == query + else: + mock_console.print.assert_any_call( + f"Query '[green]{query_name}[/green]' was [red]NOT[/red] replaced." + ) + + # Check that the query was not updated in the file + saved_data = saved_queries._load_saved_data() + assert query_name in saved_data + assert saved_data[query_name]["query"] == "source=test" + else: + # Try to save a query when there's no query to save + result = saved_queries.saving_query(query_name, query, language) + + # Check that the query was not saved + assert result is expected_success + mock_console.print.assert_called_with( + "[bold red]ERROR:[/bold red] [red]Please execute a query first.[/red]" + ) + + self.print_test_info(f"{description} (Test #{test_id})", expected_result) + + @pytest.mark.parametrize( + "test_id, description, query_name, query, language, mock_result, exception, expected_success, expected_result", + [ + ( + 1, + "Loading Query - Success", + "test_query", + "source=test", + "PPL", + (True, "result", "formatted_result"), + None, + True, + "Query loaded and executed successfully", + ), + ( + 2, + "Loading Query - Not Exists", + "nonexistent_query", + None, + None, + None, + None, + False, + "Query not loaded as expected", + ), + ( + 3, + "Loading Query - Execution Error", + "test_query", + "source=test", + "PPL", + None, + Exception("Test exception"), + False, + "Error handled correctly", + ), + ], + ) + def test_loading_query_commands( + self, + test_id, + description, + query_name, + query, + language, + mock_result, + exception, + expected_success, + expected_result, + saved_queries, + mock_console, + mock_connection, + ): + """ + Test loading and executing saved queries. + """ + self.print_test_info(f"{description} (Test #{test_id})") + + # For tests that need a query to exist first + if query and "Not Exists" not in description: + saved_queries.save_query(query_name, query, language) + + # For tests with mocked execute_query + if "Success" in description or "Error" in description: + with patch( + "opensearchsql_cli.query.execute_query.ExecuteQuery.execute_query" + ) as mock_execute_query: + if exception: + # Configure the mock to raise an exception when called + mock_execute_query.side_effect = exception + else: + # Mock the execute_query method + mock_execute_query.return_value = mock_result + + # Load and execute the query + success, result_query, formatted_result, result_language = ( + saved_queries.loading_query(query_name, mock_connection) + ) + + # Check that the query was loaded and executed or not based on expected_success + assert success is expected_success + + if expected_success: + assert result_query == query + assert formatted_result == "formatted_result" + assert result_language == language + mock_execute_query.assert_called_once() + elif exception: + assert result_query == "" + assert formatted_result == "" + assert result_language == "" + mock_console.print.assert_called_with( + f"[bold red]ERROR:[/bold red] [red] Unable to execute [/red] {str(exception)}" + ) + else: + # Try to load and execute a query that doesn't exist + success, result_query, formatted_result, result_language = ( + saved_queries.loading_query(query_name, mock_connection) + ) + + # Check that the query was not loaded + assert success is expected_success + assert result_query == "" + assert formatted_result == "" + assert result_language == "" + mock_console.print.assert_called_with( + f"[bold red]ERROR:[/bold red] Saved Query '[green]{query_name}[/green]' does not exist." + ) + + self.print_test_info(f"{description} (Test #{test_id})", expected_result) diff --git a/src/main/python/opensearchsql_cli/tests/sql/__init__.py b/src/main/python/opensearchsql_cli/tests/sql/__init__.py new file mode 100644 index 0000000..5b16e52 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/sql/__init__.py @@ -0,0 +1,5 @@ +""" +SQL tests package. + +This package contains tests for the SQL module. +""" diff --git a/src/main/python/opensearchsql_cli/tests/sql/conftest.py b/src/main/python/opensearchsql_cli/tests/sql/conftest.py new file mode 100644 index 0000000..dad9ad6 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/sql/conftest.py @@ -0,0 +1,65 @@ +""" +Pytest fixtures for SQL tests. + +This module contains fixtures used by the SQL tests. +""" + +import pytest +import subprocess +from unittest.mock import MagicMock, PropertyMock, patch + + +# Fixtures for test_sql_library.py +@pytest.fixture +def mock_process(): + """ + Fixture that returns a mock subprocess.Popen instance. + """ + mock = MagicMock() + mock.stdout.readline.side_effect = ["Gateway Server Started"] + mock.poll.return_value = None + return mock + + +@pytest.fixture +def mock_process_timeout(): + """ + Fixture that returns a mock subprocess.Popen instance that times out. + """ + mock = MagicMock() + mock.stdout.readline.side_effect = ["Some other output"] + mock.poll.return_value = None + return mock + + +# Fixtures for test_sql_connection.py +@pytest.fixture +def mock_java_gateway(): + """ + Fixture that returns a mock JavaGateway instance. + """ + mock = MagicMock() + mock.entry_point.initializeConnection.return_value = True + mock.entry_point.initializeAwsConnection.return_value = True + mock.entry_point.queryExecution.return_value = '{"result": "test data"}' + return mock + + +@pytest.fixture +def mock_sql_library_manager(): + """ + Fixture that returns a mock SqlLibraryManager instance. + """ + mock = MagicMock() + mock.started = True + mock.start.return_value = True + return mock + + +# Fixtures for test_sql_version.py +@pytest.fixture +def mock_get_all_versions(): + """Mock the get_all_versions method to return a fixed list of versions.""" + with patch("opensearchsql_cli.sql.sql_version.SqlVersion.get_all_versions") as mock: + mock.return_value = ["3.1.0.0", "2.19.0.0"] + yield mock diff --git a/src/main/python/opensearchsql_cli/tests/sql/test_sql_connection.py b/src/main/python/opensearchsql_cli/tests/sql/test_sql_connection.py new file mode 100644 index 0000000..22993fc --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/sql/test_sql_connection.py @@ -0,0 +1,69 @@ +""" +Tests for SQL Connection. + +This module contains tests for the SqlConnection class that handles +connection to SQL library and OpenSearch Cluster configuration. +""" + +import pytest +from unittest.mock import patch, MagicMock, call +from opensearchsql_cli.sql.sql_connection import SqlConnection + + +class TestSqlConnection: + """ + Test class for SqlConnection functionality. + """ + + @pytest.mark.parametrize( + "test_id, description, library_started, expected_result", + [ + (1, "Connect success when library not started", False, True), + (2, "Connect success when library already started", True, True), + ], + ) + @patch("opensearchsql_cli.sql.sql_connection.JavaGateway") + @patch("opensearchsql_cli.sql.sql_connection.sql_library_manager") + @patch("opensearchsql_cli.sql.sql_connection.console") + def test_connect( + self, + mock_console, + mock_library_manager, + mock_java_gateway, + test_id, + description, + library_started, + expected_result, + ): + """ + Test the connect method of SqlConnection. + """ + print(f"\n=== Test Case #{test_id}: {description} ===") + + # Setup mocks + mock_library_manager.started = library_started + mock_library_manager.start.return_value = True + mock_gateway = MagicMock() + mock_java_gateway.return_value = mock_gateway + + # Create connection instance + connection = SqlConnection() + + # Call connect method + result = connection.connect() + + # Verify result + assert result == expected_result + assert connection.sql_connected == expected_result + assert connection.sql_lib == mock_gateway + + # Verify library manager interaction + if not library_started: + mock_library_manager.start.assert_called_once() + else: + mock_library_manager.start.assert_not_called() + + # Verify JavaGateway creation + mock_java_gateway.assert_called_once() + + print(f"Result: {'Success' if result == expected_result else 'Failed'}") diff --git a/src/main/python/opensearchsql_cli/tests/sql/test_sql_library.py b/src/main/python/opensearchsql_cli/tests/sql/test_sql_library.py new file mode 100644 index 0000000..725322a --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/sql/test_sql_library.py @@ -0,0 +1,121 @@ +""" +Tests for SQL Library Manager. + +This module contains tests for the SQL Library Manager functionality. +""" + +import pytest +from unittest.mock import patch, MagicMock +from opensearchsql_cli.sql.sql_library_manager import SqlLibraryManager + + +class TestSqlLibraryManager: + """ + Test class for SqlLibraryManager. + """ + + @pytest.mark.parametrize( + "test_id, description, process_fixture, expected_result, expected_started, thread_called", + [ + ( + 1, + "SQL Library gateway connection: success", + "mock_process", + True, + True, + True, + ), + ( + 2, + "SQL Library gateway connection: fail timeout", + "mock_process_timeout", + False, + False, + False, + ), + ], + ) + @patch("opensearchsql_cli.sql.sql_library_manager.sql_version") + @patch( + "opensearchsql_cli.sql.sql_library_manager.SqlLibraryManager._kill_process_on_port" + ) + @patch( + "opensearchsql_cli.sql.sql_library_manager.SqlLibraryManager._check_port_in_use" + ) + @patch("opensearchsql_cli.sql.sql_library_manager.os.path.join") + @patch("opensearchsql_cli.sql.sql_library_manager.logging") + @patch("opensearchsql_cli.sql.sql_library_manager.threading.Thread") + @patch("opensearchsql_cli.sql.sql_library_manager.subprocess.Popen") + @patch("opensearchsql_cli.sql.sql_library_manager.os.makedirs") + @patch("opensearchsql_cli.sql.sql_library_manager.config_manager") + def test_gateway_connection( + self, + mock_config_manager, + mock_makedirs, + mock_popen, + mock_thread, + mock_logging, + mock_join, + mock_check_port, + mock_kill_port, + mock_sql_version, + test_id, + description, + process_fixture, + expected_result, + expected_started, + thread_called, + request, + ): + """ + Test cases for SQL Library gateway connection + """ + # Setup mocks + mock_check_port.return_value = False # Port is NOT in use to avoid early return + mock_kill_port.return_value = True # Successfully killed process + mock_join.return_value = "/mock/path" + mock_makedirs.return_value = None # Mock os.makedirs + mock_config_manager.get.return_value = "" # Mock config_manager.get + + # Mock sql_version + mock_sql_version.version = "3.1.0.0" + mock_sql_version.get_jar_path.return_value = ( + "/mock/path/opensearchsql-v3.1.0.0.jar" + ) + + # Mock logger + mock_logger = MagicMock() + mock_logging.getLogger.return_value = mock_logger + mock_file_handler = MagicMock() + mock_logging.FileHandler.return_value = mock_file_handler + + # For test 1 (success), ensure the thread is called + if test_id == 1: + # Mock the thread + thread_instance = MagicMock() + mock_thread.return_value = thread_instance + + # Mock the process to return "Gateway Server Started" + mock_process = MagicMock() + mock_process.stdout.readline.side_effect = ["Gateway Server Started"] + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + else: + # For test 2 (timeout), use the fixture + mock_process = request.getfixturevalue(process_fixture) + mock_popen.return_value = mock_process + + # Create manager and start + manager = SqlLibraryManager() + result = manager.start() + + # Assertions + assert result is expected_result + assert manager.started is expected_started + + # Popen should be called in both cases + mock_popen.assert_called_once() + + # Thread should only be called in the success case + if thread_called: + mock_thread.assert_called_once() diff --git a/src/main/python/opensearchsql_cli/tests/sql/test_sql_version.py b/src/main/python/opensearchsql_cli/tests/sql/test_sql_version.py new file mode 100644 index 0000000..fd110cb --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/sql/test_sql_version.py @@ -0,0 +1,158 @@ +""" +Tests for SQL Version Management. + +This module contains tests for the SQL Version Management functionality. +""" + +import os +import pytest +from unittest.mock import patch, MagicMock +from opensearchsql_cli.sql.sql_version import SqlVersion + + +class TestSqlVersion: + """ + Test class for SqlVersion. + """ + + @pytest.mark.parametrize( + "test_id, description, version, rebuild, jar_exists, expected_result", + [ + (1, "SQL version: success", "3.1", False, True, True), + (2, "SQL version: unsupported fail", "4.0", False, False, False), + (3, "SQL version: invalid format", "invalid", False, False, False), + ], + ) + @patch("opensearchsql_cli.sql.sql_version.os.path.exists") + @patch("opensearchsql_cli.sql.sql_version.os.path.join") + @patch("opensearchsql_cli.sql.sql_version.console") + def test_set_version( + self, + mock_console, + mock_join, + mock_exists, + test_id, + description, + version, + rebuild, + jar_exists, + expected_result, + mock_get_all_versions, + ): + """ + Test cases for SQL version selection + """ + # Setup mocks + mock_exists.return_value = jar_exists + mock_join.return_value = "/mock/path/opensearchsqlcli-3.1.0.0.jar" + + # For failure cases, ensure the requested version is not in the list + if test_id != 1: + mock_get_all_versions.return_value = [ + v + for v in mock_get_all_versions.return_value + if not v.startswith(version) + ] + + # Create version manager and set version + version_manager = SqlVersion() + result = version_manager.set_version(version, rebuild) + + # Assertions + assert result is expected_result + + if expected_result: + # For successful version setting + assert version_manager.version.startswith(version) + else: + # For failed version setting + if "invalid" in version: + # Invalid format case + mock_console.print.assert_any_call( + f"[bold red]ERROR:[/bold red] [red]Version {version} is currently not supported[/red]" + ) + else: + # Unsupported version case + mock_console.print.assert_any_call( + f"[bold red]ERROR:[/bold red] [red]Version {version} is currently not supported[/red]" + ) + + @patch("opensearchsql_cli.sql.sql_version.os.path.dirname") + @patch("opensearchsql_cli.sql.sql_version.os.makedirs") + @patch("opensearchsql_cli.sql.sql_version.os.path.exists") + @patch("opensearchsql_cli.sql.sql_version.os.path.join") + @patch("opensearchsql_cli.sql.sql_version.subprocess.run") + @patch("opensearchsql_cli.sql.sql_version.console") + @patch("opensearchsql_cli.sql.sql_version.open", create=True) + def test_rebuild_jar( + self, + mock_open, + mock_console, + mock_run, + mock_join, + mock_exists, + mock_makedirs, + mock_dirname, + mock_get_all_versions, + ): + """ + Test rebuilding JAR file + """ + # Make sure version is in the list of available versions + if "3.1.0.0" not in mock_get_all_versions.return_value: + mock_get_all_versions.return_value = [ + "3.1.0.0" + ] + mock_get_all_versions.return_value + + # Setup mocks to simulate JAR not existing initially but created after build + mock_exists.side_effect = [ + False, + True, + ] # First call returns False, second call returns True + + # Need to handle multiple calls to os.path.join + def join_side_effect(*args): + if args[-1] == "sqlcli_build.log": + return "/mock/path/sqlcli_build.log" + else: + return "/mock/path/opensearchsqlcli-3.1.0.0.jar" + + mock_join.side_effect = join_side_effect + mock_dirname.return_value = "/mock/path" + mock_run.return_value = MagicMock(returncode=0) + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + + # Create version manager and set version with rebuild + version_manager = SqlVersion() + result = version_manager.set_version("3.1", rebuild=True) + + # Assertions + assert result is True + assert mock_run.call_count == 2 + mock_console.print.assert_any_call( + "[bold green]SUCCESS:[/bold green] [green]Built SQL CLI at /mock/path/opensearchsqlcli-3.1.0.0.jar[/green]" + ) + + @patch("opensearchsql_cli.sql.sql_version.PROJECT_ROOT", "/mock/project_root") + @patch("opensearchsql_cli.sql.sql_version.os.path.join") + def test_get_jar_path( + self, + mock_join, + ): + """ + Test getting JAR path + """ + # Setup mock + expected_path = "/mock/project_root/build/libs/opensearchsqlcli-3.1.0.0.jar" + mock_join.return_value = expected_path + + # Create version manager and get JAR path + version_manager = SqlVersion() + path = version_manager.get_jar_path() + + # Assertions + assert path == expected_path + mock_join.assert_called_with( + "/mock/project_root", "build", "libs", "opensearchsqlcli-3.1.0.0.jar" + ) diff --git a/src/main/python/opensearchsql_cli/tests/sql/test_verify_cluster.py b/src/main/python/opensearchsql_cli/tests/sql/test_verify_cluster.py new file mode 100644 index 0000000..30c7aae --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/sql/test_verify_cluster.py @@ -0,0 +1,227 @@ +""" +Tests for verify_cluster.py using VCR with parametrized tests. + +This module contains tests for the VerifyCluster class that handles +verification of connections to OpenSearch clusters, +using vcrpy to record and replay HTTP interactions. +""" + +import pytest +import vcr +import os +from unittest.mock import patch, MagicMock +from opensearchsql_cli.sql.verify_cluster import VerifyCluster + +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +CASSETTES_DIR = os.path.join(CURRENT_DIR, "vcr_cassettes") + +# Create a custom VCR instance with specific settings +my_vcr = vcr.VCR( + cassette_library_dir=CASSETTES_DIR, + record_mode="once", + match_on=["uri", "method"], + filter_headers=["authorization"], # Don't record authorization headers +) + + +class TestVerifyCluster: + """ + Test class for VerifyCluster functionality using VCR. + """ + + @pytest.mark.parametrize( + "test_id," + "description," + "host," + "port," + "protocol," + "username," + "password," + "ignore_ssl," + "expected_success," + "expected_message", + [ + # HTTP Tests + ( + 1, + "HTTP success", + "localhost", + 9200, + "http", + None, + None, + False, + True, + "success", + ), + # HTTPS Tests + ( + 2, + "HTTPS success with auth", + "localhost", + 9201, + "https", + "admin", + "correct", + True, + True, + "success", + ), + ( + 3, + "HTTPS fail with no auth provided", + "localhost", + 9201, + "https", + None, + None, + True, + False, + "Unable to connect", + ), + ( + 4, + "HTTPS fail with incorrect auth", + "localhost", + 9201, + "https", + "admin", + "wrong", + True, + False, + "Unable to connect", + ), + ], + ) + def test_verify_opensearch_connection_vcr( + self, + test_id, + description, + host, + port, + protocol, + username, + password, + ignore_ssl, + expected_success, + expected_message, + ): + """ + Test the verify_opensearch_connection method for different scenarios using VCR. + + This test uses a dynamic cassette name based on the test parameters. + """ + + print(f"\n=== Test #{test_id}: {description} ===") + + cassette_name = f"opensearch_connection_{protocol}_{host}_{port}_{test_id}.yaml" + + # Use VCR with the specific cassette for this test case + with my_vcr.use_cassette(cassette_name): + # Store the input username to compare with the returned username + input_username = username + + success, message, version, url, returned_username, client = ( + VerifyCluster.verify_opensearch_connection( + host, port, protocol, username, password, ignore_ssl + ) + ) + + # Verify the results + assert success == expected_success + assert expected_message in message + + if expected_success: + assert version is not None + assert url == f"{protocol}://{host}:{port}" + assert returned_username == input_username + + print(f"Result: {'Success' if success else 'Failure'}, Message: {message}") + + @pytest.mark.parametrize( + "test_id, " + "description, " + "host, " + "mock_credentials, " + "mock_region, " + "expected_success, " + "expected_message", + [ + # AWS Tests + ( + 1, + "AWS success", + "search-cli-test-r2qtaiqbhsnh5dgwvzkcnd5l2y.us-east-2.es.amazonaws.com", + True, + "us-east-2", + True, + "success", + ), + ( + 2, + "AWS fail with 403", + "search-cli-test-r2qtaiqbhsnh5dgwvzkcnd5l2y.us-east-2.es.amazonaws.com", + True, + "us-west-2", + False, + "Unable to connect", + ), + ], + ) + def test_verify_aws_opensearch_connection_vcr( + self, + test_id, + description, + host, + mock_credentials, + mock_region, + expected_success, + expected_message, + ): + """ + Test the verify_aws_opensearch_connection method for different scenarios. + + This test uses a hybrid approach: + - For success cases, it uses VCR to record/replay real HTTP interactions with real AWS credentials + - For failure cases, it uses mocks to simulate error conditions + """ + + print(f"\n=== Test #{test_id}: {description} ===") + + cassette_name = f"aws_connection_{test_id}.yaml" + + # Create a mock for boto3.Session and AWS4Auth + mock_credentials = MagicMock() + mock_credentials.access_key = "mock_access_key" + mock_credentials.secret_key = "mock_secret_key" + mock_credentials.token = "mock_token" + + mock_session = MagicMock() + mock_session.get_credentials.return_value = mock_credentials + mock_session.region_name = mock_region + + # Mock the AWS4Auth class to avoid authentication issues + mock_aws_auth = MagicMock() + + # Use VCR for all test cases with mocked boto3.Session and AWS4Auth + with my_vcr.use_cassette(cassette_name), patch( + "boto3.Session", return_value=mock_session + ), patch("requests_aws4auth.AWS4Auth", return_value=mock_aws_auth): + + success, message, version, url, region, client = ( + VerifyCluster.verify_aws_opensearch_connection(host) + ) + print( + f"Success: {success}, Message: {message}, Version: {version}, URL: {url}, Region: {region}" + ) + + # Verify the results + assert success == expected_success + assert expected_message in message + + if expected_success: + assert version is not None + assert url == f"https://{host}" + assert region == mock_region + + print(f"Result: {'Success' if success else 'Failure'}, Message: {message}") diff --git a/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/aws_connection_1.yaml b/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/aws_connection_1.yaml new file mode 100644 index 0000000..3cafedb --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/aws_connection_1.yaml @@ -0,0 +1,47 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.4 + x-amz-content-sha256: + - e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + x-amz-date: + - 20250719T235348Z + x-amz-security-token: + - IQoJb3JpZ2luX2VjEMj//////////wEaCXVzLWVhc3QtMSJHMEUCIC9yS0Zy2t1JadZwA+qmXzDjPjaa5wDxHvHwdQLdbkuzAiEAvmnYR342Ge8CiXS6AlDoeDVcxwZg/ADGS45uQ/sGGxoquAMI4f//////////ARABGgwwNTMyNTA0MjQxNTEiDERLOF03h3yUiGyL3iqMA4vf1ozHitcdi7KGKNqkpGSHuE70sX9RlQyJZWFDqNQMe89mmNmj9PGey0CslKsLpPxFFufd27V72dcTWqBO9PgHXxLtCtxNqUpbNPrKggcgNcdtECx5IOrEDtMu83S9XlKiQoJqWsHNTXJr85dpRDA4eetdf++YpcAXInQzWSKJ/MWrrcnOCIpWrzMOnNXS6aoINIZNqr7TdlUOMXX0Qq7im8tOkKAF7xCGhV5KZXV4sFMDkSZqqbIaibNFoWgVkfLzcTQ5pNT/D7po0nAOS5Tg/pfz+UZQ+FIVGApS7zCpw1eKyTPVZc+xfh78TxwmGnmIDpk1mI/45FrMvtyqe3661g/SsUQgx37j9RJWevuiEXqePfE38RBndhtDyU2Y+g9Pqfq5OdiXW4LV2K9Q6uV/o2v2JEM2NHFN5fviXOYWGF7rw0X1IoPwHPsqSTsIZboYknPXSg4OXg2ABC1H8wNqvzXFFTBoCnbkqORxLTWDLoanVtsvN/kgmvqJMIyawrfehQnGl+tdgG/qcDDmovvDBjqdAdgIuxIpUUvdd7JQzr8v+RguiU9Z+GTJ0OTq5Ho0UAly9G6mrn0nDRTS4AKApC9Rs+newT26RfvvV4KVV4MwnKtH0zwwNlMvTUYA8F0/TzQ2cBMDLfpYiS0uXfxBXPloOOu/DwloG2Tfv6fU18c024abW3GiVqhvO1KF1S7yp7TgvkG1hEx3acAac6u6AW4TIw7eOA5QvrWoRrxoFL0= + method: GET + uri: https://search-cli-test-r2qtaiqbhsnh5dgwvzkcnd5l2y.us-east-2.es.amazonaws.com/ + response: + body: + string: "{\n \"name\" : \"76f3cf63b9ccda930880ecd939207c7b\",\n \"cluster_name\" + : \"053250424151:cli-test\",\n \"cluster_uuid\" : \"iFV4zOeaTtqr62JUJFdPZg\",\n + \ \"version\" : {\n \"distribution\" : \"opensearch\",\n \"number\" + : \"2.19.0\",\n \"build_type\" : \"tar\",\n \"build_hash\" : \"unknown\",\n + \ \"build_date\" : \"2025-04-17T09:18:30.665390993Z\",\n \"build_snapshot\" + : false,\n \"lucene_version\" : \"9.12.1\",\n \"minimum_wire_compatibility_version\" + : \"7.10.0\",\n \"minimum_index_compatibility_version\" : \"7.0.0\"\n },\n + \ \"tagline\" : \"The OpenSearch Project: https://opensearch.org/\"\n}\n" + headers: + Connection: + - keep-alive + Content-Length: + - '346' + Content-Type: + - application/json; charset=UTF-8 + Date: + - Mon, 21 Jul 2025 23:53:48 GMT + access-control-allow-origin: + - '*' + content-encoding: + - gzip + status: + code: 200 + message: OK +version: 1 diff --git a/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/aws_connection_2.yaml b/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/aws_connection_2.yaml new file mode 100644 index 0000000..2a13aa8 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/aws_connection_2.yaml @@ -0,0 +1,42 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.4 + x-amz-content-sha256: + - e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + x-amz-date: + - 20250718T032424Z + x-amz-security-token: + - IQoJb3JpZ2luX2VjEKz//////////wEaCXVzLWVhc3QtMSJHMEUCIQDICQ9mzzOGMli2rsXWXeGJkkXcxeEgR14qAIVWU/llWQIgOYdi00EBOdpViIMYPuX7s5k4USCmiPqRiDpSGPrrYi8quAMIlf//////////ARABGgwwNTMyNTA0MjQxNTEiDP85Ovm3JLDBTV0QdyqMA+eDbYozAcRezPYXoVjHHjOCYvz2O048bZY/sRUjjY4IB3uXrpWQMvHHCAVs2Acn3HvIcAr6B+Uolyr7LZHeX9g4pjWY+d9LHHCSbCcRkFEeDnadmwhoXGES95Stfl8EsoI/52eRf5sU4ad14s5qvChMhVJzExp+tOmmCuzQaefmd7TtIG95ZAo22daRTj8a7p50qcZ5HAwlvP1rGBKFDF2l3/PRxITQ/hGqqTI5+Dq65wi4ydQG1NW+TJ8/bTFSqQkBzVTYrdcgeN3Pp6HINWjMOVoCOppWOH7/UDKWrB0UBOBQ8GPmYD4pEd9zsT7bxwZfoBz2TzNCC9wf6pFEes720vD60D/7Uq1FPumsjerpUTxzzHZzdP7mGNrW26hjAlpuVQcqutNWsy3z6nQ667qEzlm6KxGQ2d3oNjmO8oYsqWbYIWGwJt2gz/IOCrXYRdnJp9LQd0uJYYfNdO2e1zD2sAq68bsD9VG+Tm0VTIT3w+XCCBbiSJKQAugziShjjnD47slcWFpIrI+BbjCYq8zCBjqdAdEqAvMeVY+hzEMmHilt126I6bWo0qixqmhCPxvon0Ka7BO0i3FF6O5QxcGxn83NANBstVmjgidi6gmj1GKBU3GEytrQILATyklh5PDXuPPjwXv4dqpvAuMLvxbIine1XG6fHnAkKS4h9yhRmf1TsavS5WsAdfjTaK4hJz2RBCVSMq3YkO59qKnPi9Pgi8DrpqH0VA/TEIRPf6U+W7Y= + method: GET + uri: https://search-cli-test-r2qtaiqbhsnh5dgwvzkcnd5l2y.us-east-2.es.amazonaws.com/ + response: + body: + string: '{"message":"The security token included in the request is invalid"}' + headers: + Connection: + - keep-alive + Content-Length: + - '88' + Content-Type: + - application/json + Date: + - Tue, 22 Jul 2025 03:24:24 GMT + access-control-allow-origin: + - '*' + content-encoding: + - gzip + x-amzn-requestid: + - 06c3e026-d20c-44b8-877d-d09f5af0ee37 + status: + code: 403 + message: Forbidden +version: 1 diff --git a/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/opensearch_connection_http_localhost_9200_1.yaml b/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/opensearch_connection_http_localhost_9200_1.yaml new file mode 100644 index 0000000..534ddd5 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/opensearch_connection_http_localhost_9200_1.yaml @@ -0,0 +1,37 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-requests/2.32.4 + method: GET + uri: http://localhost:9200/ + response: + body: + string: "{\n \"name\" : \"bcd0745d17f3\",\n \"cluster_name\" : \"opensearch\",\n + \ \"cluster_uuid\" : \"18fMzOVfQ0KPACKZXl2dbA\",\n \"version\" : {\n \"distribution\" + : \"opensearch\",\n \"number\" : \"3.1.0-SNAPSHOT\",\n \"build_type\" + : \"tar\",\n \"build_hash\" : \"666c13a361ab72abee118d7b8aa8b7a26adbdfcf\",\n + \ \"build_date\" : \"2025-05-28T21:53:58.454284Z\",\n \"build_snapshot\" + : true,\n \"lucene_version\" : \"10.2.1\",\n \"minimum_wire_compatibility_version\" + : \"2.19.0\",\n \"minimum_index_compatibility_version\" : \"2.0.0\"\n },\n + \ \"tagline\" : \"The OpenSearch Project: https://opensearch.org/\"\n}\n" + headers: + X-OpenSearch-Version: + - OpenSearch/3.1.0-SNAPSHOT (opensearch) + content-length: + - '568' + content-type: + - application/json; charset=UTF-8 + status: + code: 200 + message: OK +version: 1 diff --git a/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/opensearch_connection_https_localhost_9201_2.yaml b/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/opensearch_connection_https_localhost_9201_2.yaml new file mode 100644 index 0000000..b5b917d --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/opensearch_connection_https_localhost_9201_2.yaml @@ -0,0 +1,37 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-requests/2.32.4 + method: GET + uri: https://localhost:9201/ + response: + body: + string: "{\n \"name\" : \"bcd0745d17f3\",\n \"cluster_name\" : \"opensearch\",\n + \ \"cluster_uuid\" : \"18fMzOVfQ0KPACKZXl2dbA\",\n \"version\" : {\n \"distribution\" + : \"opensearch\",\n \"number\" : \"3.1.0-SNAPSHOT\",\n \"build_type\" + : \"tar\",\n \"build_hash\" : \"666c13a361ab72abee118d7b8aa8b7a26adbdfcf\",\n + \ \"build_date\" : \"2025-05-28T21:53:58.454284Z\",\n \"build_snapshot\" + : true,\n \"lucene_version\" : \"10.2.1\",\n \"minimum_wire_compatibility_version\" + : \"2.19.0\",\n \"minimum_index_compatibility_version\" : \"2.0.0\"\n },\n + \ \"tagline\" : \"The OpenSearch Project: https://opensearch.org/\"\n}\n" + headers: + X-OpenSearch-Version: + - OpenSearch/3.1.0-SNAPSHOT (opensearch) + content-length: + - '568' + content-type: + - application/json; charset=UTF-8 + status: + code: 200 + message: OK +version: 1 diff --git a/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/opensearch_connection_https_localhost_9201_3.yaml b/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/opensearch_connection_https_localhost_9201_3.yaml new file mode 100644 index 0000000..194d602 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/opensearch_connection_https_localhost_9201_3.yaml @@ -0,0 +1,32 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-requests/2.32.4 + method: GET + uri: https://localhost:9201/ + response: + body: + string: Unauthorized + headers: + WWW-Authenticate: + - Basic realm="OpenSearch Security" + X-OpenSearch-Version: + - OpenSearch/3.1.0-SNAPSHOT (opensearch) + content-length: + - '12' + content-type: + - text/plain; charset=UTF-8 + status: + code: 401 + message: Unauthorized +version: 1 diff --git a/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/opensearch_connection_https_localhost_9201_4.yaml b/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/opensearch_connection_https_localhost_9201_4.yaml new file mode 100644 index 0000000..194d602 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/sql/vcr_cassettes/opensearch_connection_https_localhost_9201_4.yaml @@ -0,0 +1,32 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-requests/2.32.4 + method: GET + uri: https://localhost:9201/ + response: + body: + string: Unauthorized + headers: + WWW-Authenticate: + - Basic realm="OpenSearch Security" + X-OpenSearch-Version: + - OpenSearch/3.1.0-SNAPSHOT (opensearch) + content-length: + - '12' + content-type: + - text/plain; charset=UTF-8 + status: + code: 401 + message: Unauthorized +version: 1 diff --git a/src/main/python/opensearchsql_cli/tests/test_interactive.py b/src/main/python/opensearchsql_cli/tests/test_interactive.py new file mode 100644 index 0000000..4e7efd2 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/test_interactive.py @@ -0,0 +1,306 @@ +""" +Tests for Interactive Shell + +This module contains tests for the InteractiveShell class that handles +interactive shell functionality for OpenSearch SQL CLI. +""" + +import os +import pytest +from unittest.mock import patch, MagicMock, call +from prompt_toolkit.history import FileHistory +from prompt_toolkit.shortcuts import PromptSession + +from ..interactive_shell import InteractiveShell +from ..literals.opensearch_literals import Literals + + +class TestInteractiveShell: + """ + Test class for InteractiveShell functionality. + """ + + def test_init(self, mock_sql_connection, mock_saved_queries): + """Test initialization of InteractiveShell.""" + with patch("os.path.exists", return_value=False), patch( + "builtins.open", MagicMock() + ) as mock_open: + + shell = InteractiveShell(mock_sql_connection, mock_saved_queries) + + # Verify history file creation + mock_open.assert_called_once() + + # Verify initial state + assert shell.language_mode == "ppl" + assert shell.is_ppl_mode is True + assert shell.format == "table" + assert shell.is_vertical is False + assert shell.latest_query is None + assert shell.sql_connection == mock_sql_connection + assert shell.saved_queries == mock_saved_queries + + @patch("opensearchsql_cli.interactive_shell.console") + def test_display_help_shell(self, mock_console): + """Test display_help_shell static method.""" + InteractiveShell.display_help_shell() + mock_console.print.assert_called_once() + # Verify help text contains key commands + help_text = mock_console.print.call_args[0][0] + assert "Commands:" in help_text + assert "Execute query" in help_text + assert "Change language: PPL, SQL" in help_text + assert "Change format: JSON, Table, CSV" in help_text + assert "Toggle vertical display mode" in help_text + assert "Save the latest query with a name" in help_text + assert "Exit interactive mode" in help_text + + @pytest.mark.parametrize( + "language_mode, expected_lang, mock_keywords, mock_functions, expected_keywords, expected_functions", + [ + ( + "ppl", + "ppl", + ["source", "where", "fields"], + ["count", "sum", "avg"], + ["SOURCE", "WHERE"], + ["COUNT", "SUM"], + ), + ( + "sql", + "sql", + ["SELECT", "FROM", "WHERE"], + ["COUNT", "SUM", "AVG"], + ["SELECT", "FROM"], + ["COUNT", "SUM"], + ), + ], + ) + @patch("opensearchsql_cli.interactive_shell.Literals.get_literals") + def test_auto_completer( + self, + mock_get_literals, + language_mode, + expected_lang, + mock_keywords, + mock_functions, + expected_keywords, + expected_functions, + ): + """Test auto_completer method with different language modes.""" + mock_get_literals.return_value = { + "keywords": mock_keywords, + "functions": mock_functions, + } + + shell = InteractiveShell(MagicMock(), MagicMock()) + completer = shell.auto_completer(language_mode) + + # Verify get_literals was called with correct language + mock_get_literals.assert_called_once_with(expected_lang) + + # Verify completer contains keywords, functions, and commands + words = completer.words + for keyword in expected_keywords: + assert keyword in words # Keywords + for function in expected_functions: + assert function in words # Functions + assert "-l" in words # Command + assert "-f" in words # Command + assert "help" in words # Command + assert "--save" in words # Option + + @pytest.mark.parametrize( + "query, is_explain", + [ + ("source=test | fields name", False), + ("explain source=test | fields name", True), + ], + ) + @patch("opensearchsql_cli.interactive_shell.ExecuteQuery") + def test_execute_query(self, mock_execute_query, query, is_explain): + """Test execute_query method with different query types.""" + mock_execute_query.execute_query.return_value = ( + True, + "result", + "formatted_result", + ) + + shell = InteractiveShell(MagicMock(), MagicMock()) + shell.is_ppl_mode = True + shell.format = "table" + shell.is_vertical = False + + result = shell.execute_query(query) + + # Verify query was stored + assert shell.latest_query == query + + # Verify ExecuteQuery.execute_query was called with correct parameters + mock_execute_query.execute_query.assert_called_once() + args = mock_execute_query.execute_query.call_args[0] + assert args[1] == query # query + assert args[2] is True # is_ppl_mode + assert args[3] is is_explain # is_explain + assert args[4] == "table" # format + assert args[5] is False # is_vertical + + # Verify result + assert result is True + + @patch("opensearchsql_cli.interactive_shell.ExecuteQuery") + @patch("opensearchsql_cli.interactive_shell.console") + def test_execute_query_exception(self, mock_console, mock_execute_query): + """Test execute_query method with exception.""" + mock_execute_query.execute_query.side_effect = Exception("Test error") + + shell = InteractiveShell(MagicMock(), MagicMock()) + shell.is_ppl_mode = True + shell.format = "table" + shell.is_vertical = False + + result = shell.execute_query("source=test | fields name") + + # Verify query was stored + assert shell.latest_query == "source=test | fields name" + + # Verify error was printed + mock_console.print.assert_called_once() + error_msg = mock_console.print.call_args[0][0] + assert "ERROR:" in error_msg + assert "Test error" in error_msg + + # Verify result + assert result is False + + @pytest.mark.parametrize( + "language, format_option, expected_language_mode, expected_is_ppl, expected_format, is_language_valid, is_format_valid", + [ + ("ppl", "json", "ppl", True, "json", True, True), + ("sql", "table", "sql", False, "table", True, True), + ("invalid", "table", "ppl", True, "table", False, True), + ("ppl", "invalid", "ppl", True, "table", True, False), + ], + ) + @patch("opensearchsql_cli.interactive_shell.PromptSession") + @patch("opensearchsql_cli.interactive_shell.config_manager") + @patch("opensearchsql_cli.interactive_shell.console") + def test_start_language_format( + self, + mock_console, + mock_config_manager, + mock_prompt_session, + language, + format_option, + expected_language_mode, + expected_is_ppl, + expected_format, + is_language_valid, + is_format_valid, + ): + """Test start method with various language and format combinations.""" + # Setup mocks + mock_session = MagicMock() + mock_prompt_session.return_value = mock_session + mock_session.prompt.side_effect = ["exit"] # Exit after first prompt + mock_config_manager.get_boolean.return_value = False + + # Create shell and start + shell = InteractiveShell(MagicMock(), MagicMock()) + shell.start(language, format_option) + + # Verify language and format were set correctly + assert shell.language_mode == expected_language_mode + assert shell.is_ppl_mode == expected_is_ppl + assert shell.format == expected_format + + # Verify prompt session was created and used + mock_prompt_session.assert_called_once() + mock_session.prompt.assert_called_once() + + # Verify error messages if applicable + if not is_language_valid: + mock_console.print.assert_any_call( + f"[bold red]Invalid Language:[/bold red] [red]{language.upper()}.[/red] [bold red]\nDefaulting to PPL.[/bold red]" + ) + + if not is_format_valid: + mock_console.print.assert_any_call( + f"[bold red]Invalid Format:[/bold red] [red]{format_option.upper()}.[/red] [bold red]\nDefaulting to TABLE.[/bold red]" + ) + + # Verify exit message was printed + mock_console.print.assert_any_call( + "[bold green]\nSee you next search!\n[/bold green]" + ) + + @patch("opensearchsql_cli.interactive_shell.PromptSession") + @patch("opensearchsql_cli.interactive_shell.config_manager") + def test_start_command_processing(self, mock_config_manager, mock_prompt_session): + """Test start method command processing.""" + # Setup mocks + mock_session = MagicMock() + mock_prompt_session.return_value = mock_session + + # Simulate user inputs + mock_session.prompt.side_effect = [ + "help", + "-l sql", + "-l ppl", + "-f json", + "-f invalid", + "-v", + "-s --list", + "-s --save test", + "-s --load test", + "-s --remove test", + "-s", + "select * from test", + "exit", + ] + + mock_config_manager.get_boolean.return_value = False + + # Create shell with mocked dependencies + shell = InteractiveShell(MagicMock(), MagicMock()) + shell.execute_query = MagicMock(return_value=True) + shell.latest_query = "select * from test" + + # Configure the loading_query mock to return expected values + shell.saved_queries.loading_query.return_value = ( + True, + "select * from test", + "result", + "SQL", + ) + + # Start the shell + shell.start("ppl", "table") + + # Verify saved queries methods were called + shell.saved_queries.list_saved_queries.assert_called_once() + shell.saved_queries.saving_query.assert_called_once_with( + "test", "select * from test", "ppl" + ) + shell.saved_queries.loading_query.assert_called_once() + shell.saved_queries.removing_query.assert_called_once_with("test") + + # Verify query execution + shell.execute_query.assert_called_once_with("select * from test") + + @patch("opensearchsql_cli.interactive_shell.PromptSession") + @patch("opensearchsql_cli.interactive_shell.config_manager") + def test_start_keyboard_interrupt(self, mock_config_manager, mock_prompt_session): + """Test start method with keyboard interrupt.""" + # Setup mocks + mock_session = MagicMock() + mock_prompt_session.return_value = mock_session + mock_session.prompt.side_effect = KeyboardInterrupt() + mock_config_manager.get_boolean.return_value = False + + # Create shell and start + shell = InteractiveShell(MagicMock(), MagicMock()) + shell.start("ppl", "table") + + # Verify prompt was called + mock_session.prompt.assert_called_once() diff --git a/src/main/python/opensearchsql_cli/tests/test_main_commands.py b/src/main/python/opensearchsql_cli/tests/test_main_commands.py new file mode 100644 index 0000000..f77ed32 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/test_main_commands.py @@ -0,0 +1,697 @@ +""" +Tests for individual CLI commands. + +This module contains tests for each command-line option of the CLI application. +""" + +import pytest +import os +from unittest.mock import patch, MagicMock +from typer.testing import CliRunner +from ..main import OpenSearchSqlCli + +# Create a CLI runner for testing +runner = CliRunner() + + +class TestCommands: + """ + Test class for individual CLI commands. + """ + + # Test case counter + test_case_num = 0 + + def print_test_info(self, description, result=None): + """ + Print test case number, description, and result. + + Args: + description: Test case description + result: Test result (optional) + """ + + if result is None: + TestCommands.test_case_num += 1 + print(f"\n=== Test Case #{TestCommands.test_case_num}: {description} ===") + else: + print(f"Result: {result}") + + def _check_missing_arg(self, flag, expected_error_text): + """ + Helper method to test commands with missing arguments. + + Args: + flag: Command-line flag to test (e.g., "-e", "--aws-auth") + expected_error_text: Text to look for in the error message + + Returns: + tuple: (result, test_result) where: + - result: The result of the command execution + - test_result: A string describing the test result + """ + # Create a CLI instance with mocked shell + cli = OpenSearchSqlCli() + cli.shell = MagicMock() + + # Invoke the CLI with the flag but no argument + result = runner.invoke(cli.app, [flag]) + + # Verify the result + assert result.exit_code == 2 + assert expected_error_text in result.stderr + + test_result = f"Command correctly failed with missing argument error for {flag}" + return result, test_result + + def setup_cli_test( + self, + mock_console, + mock_config_manager, + mock_version_manager, + mock_library_manager, + mock_sql_connection, + mock_figlet=None, + endpoint=None, + username_password=None, + insecure=False, + aws_auth=None, + language=None, + format=None, + version=None, + local=None, + remote=None, + remote_output=None, + rebuild=False, + config=False, + connection_success=True, + error_message=None, + version_success=True, + ): + """ + Setup common test environment for CLI tests. + + Args: + mock_console: Mocked console object + mock_config_manager: Mocked config manager + mock_version_manager: Mocked version manager + mock_library_manager: Mocked library manager + mock_sql_connection: Mocked SQL connection + mock_figlet: Mocked figlet (optional) + endpoint: Endpoint parameter (optional) + username_password: Username and password in format username:password (optional) + insecure: Whether to ignore SSL certificate validation (optional) + aws_auth: AWS auth parameter (optional) + language: Language mode (optional) + format: Output format (optional) + version: SQL plugin version (optional) + rebuild: Whether to rebuild the JAR file (optional) + config: Whether to display configuration settings (optional) + connection_success: Whether the connection should succeed (optional) + error_message: Error message to return if connection fails (optional) + version_success: Whether version setting should succeed (optional) + + Returns: + tuple: (cli, command_args) + """ + + # Mock the necessary components to avoid actual connections + mock_sql_connection.connect.return_value = True + mock_sql_connection.verify_opensearch_connection.return_value = ( + connection_success + ) + mock_sql_connection.initialize_sql_library.return_value = connection_success + mock_sql_connection.version = "2.0.0" + + # Set error message if provided + if error_message: + mock_sql_connection.error_message = error_message + + # Set URL and username based on endpoint type + if aws_auth: + mock_sql_connection.url = aws_auth + mock_sql_connection.username = "us-west-2" + elif username_password and (endpoint and endpoint.startswith("https")): + # Set username for HTTPS connections + mock_sql_connection.url = endpoint + mock_sql_connection.username = username_password.split(":")[0] + else: + # For HTTP connections, set empty username + mock_sql_connection.url = endpoint or endpoint.startswith("http") + mock_sql_connection.username = "" + + # Set up version manager + mock_version_manager.version = version or "1.0.0" + mock_version_manager.set_version.return_value = version_success + mock_version_manager.set_local_version.return_value = version_success + mock_version_manager.set_remote_version.return_value = version_success + + mock_config_manager.get.side_effect = lambda section, key, default: { + ("Query", "language", "ppl"): language or "ppl", + ("Query", "format", "table"): format or "table", + ("Connection", "endpoint", ""): "", + ("Connection", "username", ""): "", + ("Connection", "password", ""): "", + ("SqlVersion", "remote_output", ""): remote_output or "", + }.get((section, key, default), default) + + mock_config_manager.get_boolean.side_effect = lambda section, key, default: { + ("Connection", "insecure", False): insecure, + ("Connection", "aws_auth", False): False, + ("Query", "vertical", False): False, + }.get((section, key, default), default) + + if mock_figlet: + mock_figlet.return_value = "OpenSearch" + + # Create a CLI instance with mocked dependencies + cli = OpenSearchSqlCli() + + # Mock the shell attribute to prevent it from being called + if not config: + cli.shell = MagicMock() + + # Prepare command arguments + command_args = [] + if endpoint: + command_args.extend(["-e", endpoint]) + if username_password: + command_args.extend(["-u", username_password]) + if insecure: + command_args.append("-k") + if aws_auth: + command_args.extend(["--aws-auth", aws_auth]) + if language: + command_args.extend(["-l", language]) + if format: + command_args.extend(["-f", format]) + if version: + command_args.extend(["--version", version]) + if local: + command_args.extend(["--local", local]) + if remote: + command_args.extend(["--remote", remote]) + if remote_output: + command_args.extend(["--output", remote_output]) + if rebuild: + command_args.append("--rebuild") + if config: + command_args.append("--config") + + return cli, command_args + + @pytest.mark.parametrize( + "test_id, description, endpoint, username_password, insecure, expected_success, error_message", + [ + ( + 1, + "HTTP success", + "test:9200", + None, + False, + True, + None, + ), + ( + 2, + "HTTPS success with auth", + "https://test:9200", + "user:pass", + False, + True, + None, + ), + ( + 3, + "HTTPS success with auth and insecure flag", + "https://test:9200", + "user:pass", + True, + True, + None, + ), + ( + 4, + "Endpoint missing argument", + None, + None, + False, + False, + "Option '-e' requires an argument.", + ), + ], + ) + @patch("opensearchsql_cli.main.sql_connection") + @patch("opensearchsql_cli.main.sql_library_manager") + @patch("opensearchsql_cli.main.sql_version") + @patch("opensearchsql_cli.main.config_manager") + @patch("opensearchsql_cli.main.console") + @patch("opensearchsql_cli.main.pyfiglet.figlet_format") + def test_endpoint_command( + self, + mock_figlet, + mock_console, + mock_config_manager, + mock_version_manager, + mock_library_manager, + mock_sql_connection, + test_id, + description, + endpoint, + username_password, + insecure, + expected_success, + error_message, + ): + """ + Test the -e -u -k commands for HTTPS connections with authentication and insecure flag. + """ + + self.print_test_info(f"{description} (Test #{test_id})") + + if endpoint == None: + # Test missing argument case + result, test_result = self._check_missing_arg( + "-e", "Option '-e' requires an argument" + ) + else: + # Setup test environment + cli, command_args = self.setup_cli_test( + mock_console, + mock_config_manager, + mock_version_manager, + mock_library_manager, + mock_sql_connection, + mock_figlet, + endpoint=endpoint, + username_password=username_password, + insecure=insecure, + connection_success=expected_success, + error_message=error_message, + ) + result = runner.invoke(cli.app, command_args) + + assert result.exit_code == 0 + + # Verify specific behavior based on expected success + if expected_success: + mock_console.print.assert_any_call( + f"[green]Endpoint:[/green] {endpoint}" + ) + if username_password: + username = username_password.split(":")[0] + mock_console.print.assert_any_call( + f"[green]User:[/green] [dim white]{username}[/dim white]" + ) + test_result = f"Successfully connected to {endpoint} with user {username_password}" + else: + test_result = f"Successfully connected to {endpoint}" + else: + mock_console.print.assert_any_call( + f"[bold red]ERROR:[/bold red] [red]{error_message}[/red]\n" + ) + test_result = f"Failed to connect to {endpoint} as expected" + + self.print_test_info(f"{description} (Test #{test_id})", test_result) + + @pytest.mark.parametrize( + "test_id, description, aws_auth, expected_success, error_message", + [ + ( + 1, + "AWS auth success", + "https://test-domain.us-west-2.es.amazonaws.com", + True, + None, + ), + ( + 2, + "AWS auth missing argument", + None, + False, + "Option '--aws-auth' requires an argument.", + ), + ], + ) + @patch("opensearchsql_cli.main.sql_connection") + @patch("opensearchsql_cli.main.sql_library_manager") + @patch("opensearchsql_cli.main.sql_version") + @patch("opensearchsql_cli.main.config_manager") + @patch("opensearchsql_cli.main.console") + @patch("opensearchsql_cli.main.pyfiglet.figlet_format") + def test_aws_auth_command( + self, + mock_figlet, + mock_console, + mock_config_manager, + mock_version_manager, + mock_library_manager, + mock_sql_connection, + test_id, + description, + aws_auth, + expected_success, + error_message, + ): + """ + Test the --aws-auth command for AWS authentication. + """ + + self.print_test_info(f"{description} (Test #{test_id})") + + if aws_auth == None: + # Test missing argument case + result, test_result = self._check_missing_arg( + "--aws-auth", "Option '--aws-auth' requires an argument" + ) + else: + # Setup test environment normally + cli, command_args = self.setup_cli_test( + mock_console, + mock_config_manager, + mock_version_manager, + mock_library_manager, + mock_sql_connection, + mock_figlet, + aws_auth=aws_auth, + connection_success=expected_success, + error_message=error_message, + ) + result = runner.invoke(cli.app, command_args) + + # For normal cases, expect exit code 0 + assert result.exit_code == 0 + + # Verify specific behavior based on expected success + if expected_success: + mock_console.print.assert_any_call( + f"[green]Endpoint:[/green] {aws_auth}" + ) + mock_console.print.assert_any_call( + f"[green]Region:[/green] [dim white]us-west-2[/dim white]" + ) + test_result = f"Successfully connected to AWS endpoint {aws_auth}" + else: + mock_console.print.assert_any_call( + f"[bold red]ERROR:[/bold red] [red]{error_message}[/red]\n" + ) + test_result = ( + f"Failed to connect to AWS endpoint {aws_auth} as expected" + ) + + self.print_test_info(f"{description} (Test #{test_id})", test_result) + + @pytest.mark.parametrize( + "test_id, description, command_type, value, expected_display, rebuild, version_success", + [ + # Language tests + (1, "PPL language", "language", "ppl", "PPL", False, True), + (2, "SQL language", "language", "sql", "SQL", False, True), + (3, "Language missing argument", "language", None, None, False, True), + # Format tests + (4, "Table format", "format", "table", "TABLE", False, True), + (5, "JSON format", "format", "json", "JSON", False, True), + (6, "CSV format", "format", "csv", "CSV", False, True), + (7, "Format missing argument", "format", None, None, False, True), + # Version tests + (8, "Valid version", "version", "3.1", "v3.1", False, True), + (9, "Version with rebuild flag", "version", "3.1", "v3.1", True, True), + (10, "Invalid version", "version", "invalid", None, False, False), + (11, "Version missing argument", "version", None, None, False, True), + # Local directory tests + (12, "Local directory", "local", "/path/to/sql", "v3.1", False, True), + (13, "Local directory with rebuild", "local", "/path", "v3.1", True, True), + # Remote git tests + (14, "Remote", "remote", "url.git", "v3.1", False, True), + (15, "Remote with rebuild", "remote", "url.git", "v3.1", True, True), + ], + ) + @patch("opensearchsql_cli.main.sql_connection") + @patch("opensearchsql_cli.main.sql_library_manager") + @patch("opensearchsql_cli.main.sql_version") + @patch("opensearchsql_cli.main.config_manager") + @patch("opensearchsql_cli.main.console") + @patch("opensearchsql_cli.main.pyfiglet.figlet_format") + def test_others_commands( + self, + mock_figlet, + mock_console, + mock_config_manager, + mock_version_manager, + mock_library_manager, + mock_sql_connection, + test_id, + description, + command_type, + value, + expected_display, + rebuild, + version_success, + ): + """ + Test for language, format, and version commands. + + Args: + command_type: Type of command to test ('language', 'format', or 'version') + value: Value to pass to the command (or None for missing argument test) + expected_display: Expected display value (or None for missing argument test) + rebuild: Whether to include the --rebuild flag (for version command only) + version_success: Whether version setting should succeed (for version command only) + """ + self.print_test_info(f"{description} (Test #{test_id})") + + # Map command type to flag + flag_map = { + "language": "-l", + "format": "-f", + "version": "--version", + "local": "--local", + "remote": "--remote", + } + flag = flag_map[command_type] + + if value is None: + # Test missing argument case + result, test_result = self._check_missing_arg( + flag, f"Option '{flag}' requires an argument" + ) + else: + # Setup test environment with appropriate parameters based on command type + kwargs = { + "endpoint": "test:9200", + command_type: value, + } + + # Add rebuild flag if needed + if rebuild: + kwargs["rebuild"] = True + kwargs["version_success"] = version_success + + cli, command_args = self.setup_cli_test( + mock_console, + mock_config_manager, + mock_version_manager, + mock_library_manager, + mock_sql_connection, + mock_figlet, + **kwargs, + ) + + result = runner.invoke(cli.app, command_args) + assert result.exit_code == 0 + + # Verify behavior based on command type + if command_type == "language": + if expected_display: + mock_console.print.assert_any_call( + f"[green]Language:[/green] [dim white]{expected_display}[/dim white]" + ) + # Verify that shell.start was called with the correct language parameter + cli.shell.start.assert_called_once_with(value, "table") + test_result = f"Language {value} displayed as {expected_display}" + + elif command_type == "format": + if expected_display: + mock_console.print.assert_any_call( + f"[green]Format:[/green] [dim white]{expected_display}[/dim white]" + ) + # Verify that shell.start was called with the correct format parameter + cli.shell.start.assert_called_once_with("ppl", value) + test_result = f"Format {value} displayed as {expected_display}" + + elif command_type == "version": + # Verify that set_version was called with the correct parameters + # Check if any of the version setting methods were called + if version_success: + # Check if set_version was called with keyword arguments + mock_version_manager.set_version.assert_called_once_with( + version=value, rebuild=rebuild + ) + + mock_console.print.assert_any_call( + f"[green]SQL:[/green] [dim white]{expected_display}[/dim white]" + ) + test_result = f"Version {value} set successfully" + else: + mock_version_manager.set_version.assert_called_once_with( + version=value, rebuild=rebuild + ) + test_result = f"Version {value} failed as expected" + + elif command_type == "local": + # Verify that set_local_version was called with the correct parameters + if version_success: + # Check if set_local_version was called with correct parameters + mock_version_manager.set_local_version.assert_called_once_with( + value, rebuild=rebuild + ) + + test_result = f"Local directory {value} set successfully" + else: + mock_version_manager.set_local_version.assert_called_once_with( + value, rebuild=rebuild + ) + test_result = f"Local directory {value} failed as expected" + + elif command_type == "remote": + # Verify that set_remote_version was called with the correct parameters + if version_success: + # Get git URL from value + git_url = value + + # Get branch name from config (default to "main") + branch_name = mock_config_manager.get( + "SqlVersion", "branch_name", "main" + ) + + # Get remote_output from mock_config_manager + remote_output = mock_config_manager.get( + "SqlVersion", "remote_output", "" + ) + + # Check if set_remote_version was called with correct parameters + mock_version_manager.set_remote_version.assert_called_once_with( + branch_name, + git_url, + rebuild=rebuild, + remote_output=remote_output, + ) + + test_result = f"Remote git {value} set successfully" + else: + # Get git URL from value + git_url = value + + # Get branch name from config (default to "main") + branch_name = mock_config_manager.get( + "SqlVersion", "branch_name", "main" + ) + + # Get remote_output from mock_config_manager + remote_output = mock_config_manager.get( + "SqlVersion", "remote_output", "" + ) + + mock_version_manager.set_remote_version.assert_called_once_with( + branch_name, + git_url, + rebuild=rebuild, + remote_output=remote_output, + ) + test_result = f"Remote git {value} failed as expected" + + self.print_test_info(f"{description} (Test #{test_id})", test_result) + + @pytest.mark.parametrize( + "test_id, description, query, language, format, expected_success", + [ + ( + 1, + "Query command success", + "SELECT * FROM accounts", + "sql", + "table", + True, + ), + ( + 2, + "PPL query command success", + "source=accounts", + "ppl", + "json", + True, + ), + ( + 3, + "Query missing argument", + None, + "ppl", + "table", + False, + ), + ], + ) + @patch("opensearchsql_cli.main.sql_connection") + @patch("opensearchsql_cli.main.sql_library_manager") + @patch("opensearchsql_cli.main.sql_version") + @patch("opensearchsql_cli.main.config_manager") + @patch("opensearchsql_cli.main.console") + @patch("opensearchsql_cli.main.pyfiglet.figlet_format") + def test_query_command( + self, + mock_figlet, + mock_console, + mock_config_manager, + mock_version_manager, + mock_library_manager, + mock_sql_connection, + test_id, + description, + query, + language, + format, + expected_success, + ): + """ + Test the -q/--query command for executing a query and exiting. + """ + self.print_test_info(f"{description} (Test #{test_id})") + + if query is None: + # Test missing argument case + result, test_result = self._check_missing_arg( + "-q", "Option '-q' requires an argument" + ) + else: + # Setup test environment + cli, command_args = self.setup_cli_test( + mock_console, + mock_config_manager, + mock_version_manager, + mock_library_manager, + mock_sql_connection, + mock_figlet, + endpoint="test:9200", + language=language, + format=format, + connection_success=True, + ) + + # Add query parameter + command_args.extend(["-q", query]) + + # Execute the command + result = runner.invoke(cli.app, command_args) + + # Verify the result + assert result.exit_code == 0 + + # Verify that execute_query was called with the correct query + cli.shell.execute_query.assert_called_once_with(query) + + # Verify that shell.start was NOT called (CLI should exit after executing query) + cli.shell.start.assert_not_called() + + test_result = f"Query '{query}' executed successfully with language={language}, format={format}" + + self.print_test_info(f"{description} (Test #{test_id})", test_result) diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..a158d80 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + + [%level][%logger{36}.%M] %msg%n + + + + + + + diff --git a/src/opensearch_sql_cli/__init__.py b/src/opensearch_sql_cli/__init__.py deleted file mode 100644 index 770a0c7..0000000 --- a/src/opensearch_sql_cli/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - -__version__ = "1.1.0" diff --git a/src/opensearch_sql_cli/conf/__init__.py b/src/opensearch_sql_cli/conf/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/opensearch_sql_cli/conf/clirc b/src/opensearch_sql_cli/conf/clirc deleted file mode 100644 index 83fd0e5..0000000 --- a/src/opensearch_sql_cli/conf/clirc +++ /dev/null @@ -1,97 +0,0 @@ -# Copyright OpenSearch Contributors -# SPDX-License-Identifier: Apache-2.0 - -# Copyright 2020, Amazon Web Services Inc. -# Licensed 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. - -# vi: ft=dosini -[main] - -# Multi-line mode allows breaking up the sql statements into multiple lines. If -# this is set to True, then the end of the statements must have a semi-colon. -# If this is set to False then sql statements can't be split into multiple -# lines. End of line (return) is considered as the end of the statement. -multi_line = True - -# If multi_line_mode is set to "opensearchsql_cli", in multi-line mode, [Enter] will execute -# the current input if the input ends in a semicolon. -# If multi_line_mode is set to "safe", in multi-line mode, [Enter] will always -# insert a newline, and [Esc] [Enter] or [Alt]-[Enter] must be used to execute -# a command. -multi_line_mode = opensearchsql_cli - -# log_file location. -# In Unix/Linux: ~/.config/opensearchsql-cli/log -# In Windows: %USERPROFILE%\AppData\Local\dbcli\opensearchsql-cli\log -# %USERPROFILE% is typically C:\Users\{username} -log_file = default - -# history_file location. -# In Unix/Linux: ~/.config/opensearchsql-cli/history -# In Windows: %USERPROFILE%\AppData\Local\dbcli\opensearchsql-cli\history -# %USERPROFILE% is typically C:\Users\{username} -history_file = default - -# Default log level. Possible values: "CRITICAL", "ERROR", "WARNING", "INFO" -# and "DEBUG". "NONE" disables logging. -log_level = INFO - -# Table format. Possible values: psql, plain, simple, grid, fancy_grid, pipe, -# ascii, double, github, orgtbl, rst, mediawiki, html, latex, latex_booktabs, -# textile, moinmoin, jira, vertical, tsv, csv. -# Recommended: psql, fancy_grid and grid. -table_format = psql - -# Syntax Style. Possible values: manni, igor, xcode, vim, autumn, vs, rrt, -# native, perldoc, borland, tango, emacs, friendly, monokai, paraiso-dark, -# colorful, murphy, bw, pastie, paraiso-light, trac, default, fruity -syntax_style = default - -# Set threshold for row limit prompt. Use 0 to disable prompt. -# maybe not now, since OpenSearch sql plugin returns 200 rows of data by default if not -# using LIMIT. -row_limit = 1000 - -# Character used to left pad multi-line queries to match the prompt size. -multiline_continuation_char = '.' - -# The string used in place of a null value. -null_string = 'null' - -# Custom colors for the completion menu, toolbar, etc. -[colors] -completion-menu.completion.current = 'bg:#ffffff #000000' -completion-menu.completion = 'bg:#008888 #ffffff' -completion-menu.meta.completion.current = 'bg:#44aaaa #000000' -completion-menu.meta.completion = 'bg:#448888 #ffffff' -completion-menu.multi-column-meta = 'bg:#aaffff #000000' -scrollbar.arrow = 'bg:#003333' -scrollbar = 'bg:#00aaaa' -selected = '#ffffff bg:#6666aa' -search = '#ffffff bg:#4444aa' -search.current = '#ffffff bg:#44aa44' -bottom-toolbar = 'bg:#222222 #aaaaaa' -bottom-toolbar.off = 'bg:#222222 #888888' -bottom-toolbar.on = 'bg:#222222 #ffffff' -search-toolbar = 'noinherit bold' -search-toolbar.text = 'nobold' -system-toolbar = 'noinherit bold' -arg-toolbar = 'noinherit bold' -arg-toolbar.text = 'nobold' -bottom-toolbar.transaction.valid = 'bg:#222222 #00ff5f bold' -bottom-toolbar.transaction.failed = 'bg:#222222 #ff005f bold' - -# style classes for colored table output -output.header = "#00ff5f bold" -output.odd-row = "" -output.even-row = "" diff --git a/src/opensearch_sql_cli/config.py b/src/opensearch_sql_cli/config.py deleted file mode 100644 index a00d956..0000000 --- a/src/opensearch_sql_cli/config.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - -import errno -import os -import platform -import shutil - -from os.path import expanduser, exists, dirname -from configobj import ConfigObj - - -def config_location(): - """Return absolute conf file path according to different OS.""" - if "XDG_CONFIG_HOME" in os.environ: - return "%s/opensearchsql-cli/" % expanduser(os.environ["XDG_CONFIG_HOME"]) - elif platform.system() == "Windows": - # USERPROFILE is typically C:\Users\{username} - return "%s\\AppData\\Local\\dbcli\\opensearchsql-cli\\" % os.getenv("USERPROFILE") - else: - return expanduser("~/.config/opensearchsql-cli/") - - -def _load_config(user_config, default_config=None): - config = ConfigObj() - config.merge(ConfigObj(default_config, interpolation=False)) - config.merge(ConfigObj(expanduser(user_config), interpolation=False, encoding="utf-8")) - config.filename = expanduser(user_config) - - return config - - -def ensure_dir_exists(path): - """ - Try to create config file in OS. - - Ignore existing destination. Raise error for other OSError, such as errno.EACCES (Permission denied), - errno.ENOSPC (No space left on device) - """ - parent_dir = expanduser(dirname(path)) - try: - os.makedirs(parent_dir) - except OSError as exc: - if exc.errno != errno.EEXIST: - raise - - -def _write_default_config(source, destination, overwrite=False): - destination = expanduser(destination) - if not overwrite and exists(destination): - return - - ensure_dir_exists(destination) - shutil.copyfile(source, destination) - - -# https://stackoverflow.com/questions/40193112/python-setuptools-distribute-configuration-files-to-os-specific-directories -def get_config(clirc_file=None): - """ - Get config for opensearchsql cli. - - This config comes from either existing config in the OS, or create a config file in the OS, and write default config - including in the package to it. - """ - from .conf import __file__ as package_root - - package_root = os.path.dirname(package_root) - - clirc_file = clirc_file or "%sconfig" % config_location() - default_config = os.path.join(package_root, "clirc") - - _write_default_config(default_config, clirc_file) - - return _load_config(clirc_file, default_config) diff --git a/src/opensearch_sql_cli/formatter.py b/src/opensearch_sql_cli/formatter.py deleted file mode 100644 index 2087bb0..0000000 --- a/src/opensearch_sql_cli/formatter.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - -import click -import itertools - -from cli_helpers.tabular_output import TabularOutputFormatter -from cli_helpers.tabular_output.preprocessors import format_numbers -from opensearchpy.exceptions import OpenSearchException - -click.disable_unicode_literals_warning = True - - -class Formatter: - """Formatter instance is used to format the data retrieved from OpenSearch.""" - - def __init__(self, settings): - """A formatter can be customized by passing settings as a parameter.""" - self.settings = settings - self.table_format = "vertical" if self.settings.is_vertical else self.settings.table_format - self.max_width = self.settings.max_width - - def format_array(val): - if val is None: - return self.settings.missingval - if not isinstance(val, list): - return val - return "[" + ",".join(str(format_array(e)) for e in val) + "]" - - def format_arrays(field_data, headers, **_): - field_data = list(field_data) - for row in field_data: - row[:] = [format_array(val) if isinstance(val, list) else val for val in row] - - return field_data, headers - - self.output_kwargs = { - "sep_title": "RECORD {n}", - "sep_character": "-", - "sep_length": (1, 25), - "missing_value": self.settings.missingval, - "preprocessors": (format_numbers, format_arrays), - "disable_numparse": True, - "preserve_whitespace": True, - "style": self.settings.style_output, - } - - def format_output(self, data): - """Format data. - - :param data: raw data get from OpenSearch - :return: formatted output, it's either table or vertical format - """ - formatter = TabularOutputFormatter(format_name=self.table_format) - - # parse response data - try: - datarows = data["datarows"] - schema = data["schema"] - total_hits = data["total"] - cur_size = data["size"] - except KeyError: - # tmp fix: in case some errors in query engine returns 200 in http response, leading to a parsing error - # TODO: remove this after #311 in sql repo is fixed - raise OpenSearchException(data) - - # unused data for now, - fields = [] - types = [] - - # get header and type as lists, for future usage - for i in schema: - fields.append(i.get("alias", i["name"])) - types.append(i["type"]) - - output = formatter.format_output(datarows, fields, **self.output_kwargs) - output_message = "fetched rows / total rows = %d/%d" % (cur_size, total_hits) - - # OpenSearch sql has a restriction of retrieving 200 rows of data by default - if total_hits > 200 == cur_size: - output_message += "\n" + "Attention: Use LIMIT keyword when retrieving more than 200 rows of data" - - # check width overflow, change format_name for better visual effect - first_line = next(output) - output = itertools.chain([output_message], [first_line], output) - - if len(first_line) > self.max_width: - click.secho(message="Output longer than terminal width", fg="red") - if click.confirm("Do you want to display data vertically for better visual effect?"): - output = formatter.format_output(datarows, fields, format_name="vertical", **self.output_kwargs) - output = itertools.chain([output_message], output) - - # TODO: if decided to add row_limit. Refer to pgcli -> main -> line 866. - - return output diff --git a/src/opensearch_sql_cli/main.py b/src/opensearch_sql_cli/main.py deleted file mode 100644 index ec2092d..0000000 --- a/src/opensearch_sql_cli/main.py +++ /dev/null @@ -1,134 +0,0 @@ -from __future__ import unicode_literals - -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - - -import click -import sys - -from .config import config_location -from .opensearch_connection import OpenSearchConnection -from .utils import OutputSettings -from .opensearchsql_cli import OpenSearchSqlCli -from .formatter import Formatter - -click.disable_unicode_literals_warning = True - - -@click.command() -@click.argument("endpoint", default="http://localhost:9200") -@click.option("-q", "--query", "query", type=click.STRING, help="Run single query in non-interactive mode") -@click.option("-e", "--explain", "explain", is_flag=True, help="Explain SQL to OpenSearch DSL") -@click.option( - "--clirc", - default=config_location() + "config", - envvar="CLIRC", - help="Location of clirc file.", - type=click.Path(dir_okay=False), -) -@click.option( - "-f", - "--format", - "result_format", - type=click.STRING, - default="jdbc", - help="Specify format of output, jdbc/csv. By default, it's jdbc", -) -@click.option( - "-v", - "--vertical", - "is_vertical", - is_flag=True, - default=False, - help="Convert output from horizontal to vertical. Only used for non-interactive mode", -) -@click.option("-u", "--username", help="Username to connect to the OpenSearch") -@click.option("-w", "--password", help="password corresponding to username") -@click.option( - "-p", - "--pager", - "always_use_pager", - is_flag=True, - default=False, - help="Always use pager to display output. If not specified, smart pager mode will be used according to the \ - length/width of output", -) -@click.option( - "--aws-auth", - "use_aws_authentication", - is_flag=True, - default=False, - help="Use AWS sigV4 to connect to AWS OpenSearch domain", -) -@click.option( - "-l", - "--language", - "query_language", - type=click.STRING, - default="sql", - help="SQL OR PPL", -) -@click.option( - "-t", - "--timeout", - "response_timeout", - type=click.INT, - default=10, - help="Timeout in seconds to await a response from the server" -) -def cli( - endpoint, - query, - explain, - clirc, - result_format, - is_vertical, - username, - password, - always_use_pager, - use_aws_authentication, - query_language, - response_timeout -): - """ - Provide endpoint for OpenSearch client. - By default, it uses http://localhost:9200 to connect. - """ - - if username and password: - http_auth = (username, password) - else: - http_auth = None - - # TODO add validation for endpoint to avoid the cost of connecting to some obviously invalid endpoint - - # handle single query without more interaction with user - if query: - opensearch_executor = OpenSearchConnection(endpoint, http_auth, use_aws_authentication) - opensearch_executor.set_connection() - if explain: - output = opensearch_executor.execute_query(query, explain=True, use_console=False) - else: - output = opensearch_executor.execute_query(query, output_format=result_format, use_console=False) - if output and result_format == "jdbc": - settings = OutputSettings(table_format="psql", is_vertical=is_vertical) - formatter = Formatter(settings) - output = formatter.format_output(output) - output = "\n".join(output) - - click.echo(output) - sys.exit(0) - - # use console to interact with user - opensearchsql_cli = OpenSearchSqlCli(clirc_file=clirc, always_use_pager=always_use_pager, - use_aws_authentication=use_aws_authentication, query_language=query_language, - response_timeout=response_timeout) - opensearchsql_cli.connect(endpoint, http_auth) - opensearchsql_cli.run_cli() - - -if __name__ == "__main__": - cli() diff --git a/src/opensearch_sql_cli/opensearch_buffer.py b/src/opensearch_sql_cli/opensearch_buffer.py deleted file mode 100644 index 33f063d..0000000 --- a/src/opensearch_sql_cli/opensearch_buffer.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import unicode_literals - -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - - -from prompt_toolkit.enums import DEFAULT_BUFFER -from prompt_toolkit.filters import Condition -from prompt_toolkit.application import get_app - - -def opensearch_is_multiline(opensearchsql_cli): - """Return function that returns boolean to enable/unable multiline mode.""" - - @Condition - def cond(): - doc = get_app().layout.get_buffer_by_name(DEFAULT_BUFFER).document - - if not opensearchsql_cli.multi_line: - return False - if opensearchsql_cli.multiline_mode == "safe": - return True - else: - return not _multiline_exception(doc.text) - - return cond - - -def _is_complete(sql): - # A complete command is an sql statement that ends with a semicolon - return sql.endswith(";") - - -def _multiline_exception(text): - text = text.strip() - return _is_complete(text) diff --git a/src/opensearch_sql_cli/opensearch_connection.py b/src/opensearch_sql_cli/opensearch_connection.py deleted file mode 100644 index 4e20686..0000000 --- a/src/opensearch_sql_cli/opensearch_connection.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - -import boto3 -import click -import logging -import ssl -import sys -import urllib3 - -from opensearchpy import OpenSearch, RequestsHttpConnection -from opensearchpy.exceptions import ConnectionError, RequestError -from opensearchpy.connection import create_ssl_context -from requests_aws4auth import AWS4Auth - - -class OpenSearchConnection: - """OpenSearchConnection instances are used to set up and maintain client to OpenSearch cluster, - as well as send user's SQL query to OpenSearch. - """ - - def __init__( - self, - endpoint=None, - http_auth=None, - use_aws_authentication=False, - query_language="sql", - response_timeout=10 - ): - """Initialize an OpenSearchConnection instance. - - Set up client and get indices list. - - :param endpoint: an url in the format of "http://localhost:9200" - :param http_auth: a tuple in the format of (username, password) - """ - self.client = None - self.ssl_context = None - self.opensearch_version = None - self.plugins = None - self.aws_auth = None - self.indices_list = [] - self.endpoint = endpoint - self.http_auth = http_auth - self.use_aws_authentication = use_aws_authentication - self.query_language = query_language - self.response_timeout = response_timeout - self.is_aws_serverless = self.use_aws_authentication and ".aoss.amazonaws.com" in self.endpoint - - def get_indices(self): - if self.client: - res = self.client.indices.get_alias().keys() - self.indices_list = list(res) - - def get_aes_client(self): - service = "es" if not self.is_aws_serverless else "aoss" - session = boto3.Session() - credentials = session.get_credentials() - region = session.region_name - - if credentials is not None: - self.aws_auth = AWS4Auth(credentials.access_key, credentials.secret_key, region, service, session_token=credentials.token) - else: - click.secho( - message="Can not retrieve your AWS credentials, check your AWS config", - fg="red", - ) - - aes_client = OpenSearch( - hosts=[self.endpoint], - http_auth=self.aws_auth, - use_ssl=True, - verify_certs=True, - connection_class=RequestsHttpConnection, - ) - - return aes_client - - def get_opensearch_client(self): - ssl_context = self.ssl_context = create_ssl_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - - opensearch_client = OpenSearch( - [self.endpoint], - http_auth=self.http_auth, - verify_certs=False, - ssl_context=ssl_context, - connection_class=RequestsHttpConnection, - ) - - return opensearch_client - - def is_sql_plugin_installed(self, opensearch_client: OpenSearch) -> bool: - if self.is_aws_serverless: - # If using serverless there's no _cat/plugins endpoint, SQL is always installed. See: - # https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless-genref.html#serverless-plugins - return True - self.plugins = opensearch_client.cat.plugins(params={"s": "component", "v": "true"}) - sql_plugin_name_list = ["opensearch-sql"] - return any(x in self.plugins for x in sql_plugin_name_list) - - def set_connection(self, is_reconnect=False): - urllib3.disable_warnings() - logging.captureWarnings(True) - - if self.http_auth: - opensearch_client = self.get_opensearch_client() - elif self.use_aws_authentication: - opensearch_client = self.get_aes_client() - else: - opensearch_client = OpenSearch([self.endpoint], verify_certs=True) - - # check connection. check OpenSearch SQL plugin availability. - try: - if not self.is_sql_plugin_installed(opensearch_client): - click.secho( - message="Must have OpenSearch SQL plugin installed in your OpenSearch" - "instance!\nCheck this out: https://github.com/opensearch-project/sql", - fg="red", - ) - click.echo(self.plugins) - sys.exit() - - if self.is_aws_serverless: - # Serverless is versionless - self.opensearch_version = "Serverless" - else: - # info() may throw ConnectionError, if connection fails to establish - info = opensearch_client.info() - self.opensearch_version = info["version"]["number"] - - self.client = opensearch_client - self.get_indices() - - except ConnectionError as error: - if is_reconnect: - # re-throw error - raise error - else: - click.secho(message="Can not connect to endpoint %s" % self.endpoint, fg="red") - click.echo(repr(error)) - sys.exit(0) - - def handle_server_close_connection(self): - """Used during CLI execution.""" - try: - click.secho(message="Reconnecting...", fg="green") - self.set_connection(is_reconnect=True) - click.secho(message="Reconnected! Please run query again", fg="green") - except ConnectionError as reconnection_err: - click.secho( - message="Connection Failed. Check your OpenSearch is running and then come back", - fg="red", - ) - click.secho(repr(reconnection_err), err=True, fg="red") - - def execute_query(self, query, output_format="jdbc", explain=False, use_console=True): - """ - Handle user input, send SQL query and get response. - - :param use_console: use console to interact with user, otherwise it's single query - :param query: SQL query - :param output_format: jdbc/csv - :param explain: if True, use _explain API. - :return: raw http response - """ - - # TODO: consider add evaluator/handler to filter obviously-invalid input, - # to save cost of http client. - # deal with input - final_query = query.strip().strip(";") - - try: - if self.query_language == "sql": - data = self.client.transport.perform_request( - url="/_plugins/_sql/_explain" if explain else "/_plugins/_sql", - method="POST", - params=None if explain else {"format": output_format, "request_timeout": self.response_timeout}, - body={"query": final_query}, - ) - else: - data = self.client.transport.perform_request( - url="/_plugins/_ppl/_explain" if explain else "/_plugins/_ppl", - method="POST", - params=None if explain else {"format": output_format, "request_timeout": self.response_timeout}, - body={"query": final_query}, - ) - return data - - # handle client lost during execution - except ConnectionError: - if use_console: - self.handle_server_close_connection() - except RequestError as error: - click.secho(message=str(error.info["error"]), fg="red") diff --git a/src/opensearch_sql_cli/opensearch_literals/__init__.py b/src/opensearch_sql_cli/opensearch_literals/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/opensearch_sql_cli/opensearch_style.py b/src/opensearch_sql_cli/opensearch_style.py deleted file mode 100644 index 7cddb83..0000000 --- a/src/opensearch_sql_cli/opensearch_style.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import unicode_literals - -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - - -import logging - -import pygments.styles -from pygments.token import string_to_tokentype, Token -from pygments.style import Style as PygmentsStyle -from pygments.util import ClassNotFound -from prompt_toolkit.styles.pygments import style_from_pygments_cls -from prompt_toolkit.styles import merge_styles, Style - -logger = logging.getLogger(__name__) - -# map Pygments tokens (ptk 1.0) to class names (ptk 2.0). -TOKEN_TO_PROMPT_STYLE = { - Token.Menu.Completions.Completion.Current: "completion-menu.completion.current", - Token.Menu.Completions.Completion: "completion-menu.completion", - Token.Menu.Completions.Meta.Current: "completion-menu.meta.completion.current", - Token.Menu.Completions.Meta: "completion-menu.meta.completion", - Token.Menu.Completions.MultiColumnMeta: "completion-menu.multi-column-meta", - Token.Menu.Completions.ProgressButton: "scrollbar.arrow", # best guess - Token.Menu.Completions.ProgressBar: "scrollbar", # best guess - Token.SelectedText: "selected", - Token.SearchMatch: "search", - Token.SearchMatch.Current: "search.current", - Token.Toolbar: "bottom-toolbar", - Token.Toolbar.Off: "bottom-toolbar.off", - Token.Toolbar.On: "bottom-toolbar.on", - Token.Toolbar.Search: "search-toolbar", - Token.Toolbar.Search.Text: "search-toolbar.text", - Token.Toolbar.System: "system-toolbar", - Token.Toolbar.Arg: "arg-toolbar", - Token.Toolbar.Arg.Text: "arg-toolbar.text", - Token.Toolbar.Transaction.Valid: "bottom-toolbar.transaction.valid", - Token.Toolbar.Transaction.Failed: "bottom-toolbar.transaction.failed", - Token.Output.Header: "output.header", - Token.Output.OddRow: "output.odd-row", - Token.Output.EvenRow: "output.even-row", -} - -# reverse dict for cli_helpers, because they still expect Pygments tokens. -PROMPT_STYLE_TO_TOKEN = {v: k for k, v in TOKEN_TO_PROMPT_STYLE.items()} - - -def style_factory(name, cli_style): - try: - style = pygments.styles.get_style_by_name(name) - except ClassNotFound: - style = pygments.styles.get_style_by_name("native") - - prompt_styles = [] - - for token in cli_style: - # treat as prompt style name (2.0). See default style names here: - # https://github.com/jonathanslenders/python-prompt-toolkit/blob/master/prompt_toolkit/styles/defaults.py - prompt_styles.append((token, cli_style[token])) - - override_style = Style([("bottom-toolbar", "noreverse")]) - return merge_styles([style_from_pygments_cls(style), override_style, Style(prompt_styles)]) - - -def style_factory_output(name, cli_style): - try: - style = pygments.styles.get_style_by_name(name).styles - except ClassNotFound: - style = pygments.styles.get_style_by_name("native").styles - - for token in cli_style: - - if token in PROMPT_STYLE_TO_TOKEN: - token_type = PROMPT_STYLE_TO_TOKEN[token] - style.update({token_type: cli_style[token]}) - else: - # TODO: cli helpers will have to switch to ptk.Style - logger.error("Unhandled style / class name: %s", token) - - class OutputStyle(PygmentsStyle): - default_style = "" - styles = style - - return OutputStyle diff --git a/src/opensearch_sql_cli/opensearchsql_cli.py b/src/opensearch_sql_cli/opensearchsql_cli.py deleted file mode 100644 index 8b14144..0000000 --- a/src/opensearch_sql_cli/opensearchsql_cli.py +++ /dev/null @@ -1,191 +0,0 @@ -from __future__ import unicode_literals - -from os.path import expanduser, expandvars - -from prompt_toolkit.history import FileHistory - -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - - -import click -import re -import pyfiglet -import os -import json - -from prompt_toolkit.completion import WordCompleter -from prompt_toolkit.enums import DEFAULT_BUFFER -from prompt_toolkit.shortcuts import PromptSession -from prompt_toolkit.filters import HasFocus, IsDone -from prompt_toolkit.lexers import PygmentsLexer -from prompt_toolkit.layout.processors import ConditionalProcessor, HighlightMatchingBracketProcessor -from prompt_toolkit.auto_suggest import AutoSuggestFromHistory -from pygments.lexers.sql import SqlLexer - -from .config import get_config, config_location -from .opensearch_connection import OpenSearchConnection -from .opensearch_buffer import opensearch_is_multiline -from .opensearch_style import style_factory, style_factory_output -from .formatter import Formatter -from .utils import OutputSettings -from . import __version__ - - -# Ref: https://stackoverflow.com/questions/30425105/filter-special-chars-such-as-color-codes-from-shell-output -COLOR_CODE_REGEX = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))") - -click.disable_unicode_literals_warning = True - - -class OpenSearchSqlCli: - """OpenSearchSqlCli instance is used to build and run the OpenSearch SQL CLI.""" - - def __init__(self, clirc_file=None, always_use_pager=False, use_aws_authentication=False, query_language="sql", - response_timeout=10): - # Load conf file - config = self.config = get_config(clirc_file) - literal = self.literal = self._get_literals() - - self.prompt_app = None - self.opensearch_executor = None - self.query_language = query_language - self.always_use_pager = always_use_pager - self.use_aws_authentication = use_aws_authentication - self.response_timeout = response_timeout - self.keywords_list = literal["keywords"] - self.functions_list = literal["functions"] - self.syntax_style = config["main"]["syntax_style"] - self.cli_style = config["colors"] - self.table_format = config["main"]["table_format"] - self.multiline_continuation_char = config["main"]["multiline_continuation_char"] - self.multi_line = config["main"].as_bool("multi_line") - self.multiline_mode = config["main"].get("multi_line_mode", "src") - self.history_file = config["main"]["history_file"] - self.null_string = config["main"].get("null_string", "null") - self.style_output = style_factory_output(self.syntax_style, self.cli_style) - - if self.history_file == "default": - self.history_file = os.path.join(config_location(), "history") - else: - self.history_file = expandvars(expanduser(self.history_file)) - - def build_cli(self): - # TODO: Optimize index suggestion to serve indices options only at the needed position, such as 'from' - indices_list = self.opensearch_executor.indices_list - sql_completer = WordCompleter(self.keywords_list + self.functions_list + indices_list, ignore_case=True) - - # https://stackoverflow.com/a/13726418 denote multiple unused arguments of callback in Python - def get_continuation(width, *_): - continuation = self.multiline_continuation_char * (width - 1) + " " - return [("class:continuation", continuation)] - - prompt_app = PromptSession( - lexer=PygmentsLexer(SqlLexer), - completer=sql_completer, - complete_while_typing=True, - history=FileHistory(self.history_file), - style=style_factory(self.syntax_style, self.cli_style), - prompt_continuation=get_continuation, - multiline=opensearch_is_multiline(self), - auto_suggest=AutoSuggestFromHistory(), - input_processors=[ - ConditionalProcessor( - processor=HighlightMatchingBracketProcessor(chars="[](){}"), - filter=HasFocus(DEFAULT_BUFFER) & ~IsDone(), - ) - ], - tempfile_suffix=".sql", - ) - - return prompt_app - - def run_cli(self): - """ - Print welcome page, goodbye message. - - Run the CLI and keep listening to user's input. - """ - self.prompt_app = self.build_cli() - - settings = OutputSettings( - max_width=self.prompt_app.output.get_size().columns, - style_output=self.style_output, - table_format=self.table_format, - missingval=self.null_string, - ) - - # print Banner - banner = pyfiglet.figlet_format("OpenSearch", font="slant") - print(banner) - - # print info on the welcome page - print("Server: OpenSearch %s" % self.opensearch_executor.opensearch_version) - print("CLI Version: %s" % __version__) - print("Endpoint: %s" % self.opensearch_executor.endpoint) - print("Query Language: %s" % self.query_language) - - while True: - try: - text = self.prompt_app.prompt(message="opensearchsql> ") - except KeyboardInterrupt: - continue # Control-C pressed. Try again. - except EOFError: - break # Control-D pressed. - - try: - output = self.opensearch_executor.execute_query(text) - if output: - formatter = Formatter(settings) - formatted_output = formatter.format_output(output) - self.echo_via_pager("\n".join(formatted_output)) - - except Exception as e: - print(repr(e)) - - print("See you next search!") - - def is_too_wide(self, line): - """Will this line be too wide to fit into terminal?""" - if not self.prompt_app: - return False - return len(COLOR_CODE_REGEX.sub("", line)) > self.prompt_app.output.get_size().columns - - def is_too_tall(self, lines): - """Are there too many lines to fit into terminal?""" - if not self.prompt_app: - return False - return len(lines) >= (self.prompt_app.output.get_size().rows - 4) - - def echo_via_pager(self, text, color=None): - lines = text.split("\n") - if self.always_use_pager: - click.echo_via_pager(text, color=color) - - elif self.is_too_tall(lines) or any(self.is_too_wide(l) for l in lines): - click.echo_via_pager(text, color=color) - else: - click.echo(text, color=color) - - def connect(self, endpoint, http_auth=None): - self.opensearch_executor = OpenSearchConnection( - endpoint, http_auth, self.use_aws_authentication, self.query_language, self.response_timeout - ) - self.opensearch_executor.set_connection() - - def _get_literals(self): - """Parse "opensearch_literals.json" with literal type of SQL "keywords" and "functions", which - are SQL keywords and functions supported by OpenSearch SQL Plugin. - - :return: a dict that is parsed from opensearch_literals.json - """ - from .opensearch_literals import __file__ as package_root - - package_root = os.path.dirname(package_root) - - literal_file = os.path.join(package_root, "opensearch_literals.json") - with open(literal_file) as f: - literals = json.load(f) - return literals diff --git a/src/opensearch_sql_cli/utils.py b/src/opensearch_sql_cli/utils.py deleted file mode 100644 index 2451d8d..0000000 --- a/src/opensearch_sql_cli/utils.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - -import sys - -from collections import namedtuple - -OutputSettings = namedtuple("OutputSettings", "table_format is_vertical max_width style_output missingval") - -OutputSettings.__new__.__defaults__ = (None, False, sys.maxsize, None, "null") diff --git a/submodule/datasources-3.1.0.0-SNAPSHOT.jar b/submodule/datasources-3.1.0.0-SNAPSHOT.jar new file mode 100644 index 0000000..bd7127f Binary files /dev/null and b/submodule/datasources-3.1.0.0-SNAPSHOT.jar differ diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/cassettes/serverless_show_tables.yaml b/tests/cassettes/serverless_show_tables.yaml deleted file mode 100644 index 37bd4a5..0000000 --- a/tests/cassettes/serverless_show_tables.yaml +++ /dev/null @@ -1,102 +0,0 @@ -interactions: -- request: - body: null - headers: - Authorization: - - AWS4-HMAC-SHA256 Credential=testing/20240924/us-east-1/aoss/aws4_request, - SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-security-token, - Signature=6835b7bf753ce295f7bcfe9eceb26c6150e3ac95b3f8b829d8ab280b851b5c6e - content-type: - - application/json - user-agent: - - opensearch-py/1.0.0 (Python 3.12.5) - x-amz-content-sha256: - - e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 - x-amz-date: - - 20240924T221825Z - x-amz-security-token: - - redacted - method: GET - uri: https://example_endpoint.beta-us-east-1.aoss.amazonaws.com:443/_alias - response: - body: - string: '{"target_index":{"aliases":{}},".opensearch_dashboards_1":{"aliases":{".opensearch_dashboards":{}}},"sample-index1":{"aliases":{}}}' - headers: - content-length: - - '131' - content-type: - - application/json; charset=UTF-8 - date: - - Tue, 24 Sep 2024 22:18:26 GMT - server: - - aoss-amazon-m - x-envoy-upstream-service-time: - - '49' - x-request-id: - - d77aa3ab-e474-98fc-b33d-1841f27e8b29 - status: - code: 200 - message: OK -- request: - body: '{"query":"SHOW TABLES LIKE %"}' - headers: - Accept-Charset: - - UTF-8 - Authorization: - - AWS4-HMAC-SHA256 Credential=testing/20240924/us-east-1/aoss/aws4_request, - SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-security-token, - Signature=272ec9c51f0be839579f97311adce6f2c757e77498f3552ac9c794e8e951cf68 - Content-Length: - - '30' - Content-Type: - - application/json - user-agent: - - opensearch-py/1.0.0 (Python 3.12.5) - x-amz-content-sha256: - - e53aaf06a94a0ef15a30e8ccb84866025b8ec4fec42338a4683a735b0fae6e9d - x-amz-date: - - 20240924T221826Z - x-amz-security-token: - - redacted - method: POST - uri: https://example_endpoint.beta-us-east-1.aoss.amazonaws.com:443/_plugins/_sql - response: - body: - string: "{\n \"schema\": [\n {\n \"name\": \"TABLE_CAT\",\n \"type\": - \"keyword\"\n },\n {\n \"name\": \"TABLE_SCHEM\",\n \"type\": - \"keyword\"\n },\n {\n \"name\": \"TABLE_NAME\",\n \"type\": - \"keyword\"\n },\n {\n \"name\": \"TABLE_TYPE\",\n \"type\": - \"keyword\"\n },\n {\n \"name\": \"REMARKS\",\n \"type\": - \"keyword\"\n },\n {\n \"name\": \"TYPE_CAT\",\n \"type\": - \"keyword\"\n },\n {\n \"name\": \"TYPE_SCHEM\",\n \"type\": - \"keyword\"\n },\n {\n \"name\": \"TYPE_NAME\",\n \"type\": - \"keyword\"\n },\n {\n \"name\": \"SELF_REFERENCING_COL_NAME\",\n - \ \"type\": \"keyword\"\n },\n {\n \"name\": \"REF_GENERATION\",\n - \ \"type\": \"keyword\"\n }\n ],\n \"datarows\": [\n [\n \"opensearch\",\n - \ null,\n \".opensearch_dashboards_1\",\n \"BASE TABLE\",\n - \ null,\n null,\n null,\n null,\n null,\n null\n - \ ],\n [\n \"opensearch\",\n null,\n \"sample-index1\",\n - \ \"BASE TABLE\",\n null,\n null,\n null,\n null,\n - \ null,\n null\n ],\n [\n \"opensearch\",\n null,\n - \ \"target_index\",\n \"BASE TABLE\",\n null,\n null,\n - \ null,\n null,\n null,\n null\n ],\n [\n \"opensearch\",\n - \ null,\n \".opensearch_dashboards\",\n \"BASE TABLE\",\n null,\n - \ null,\n null,\n null,\n null,\n null\n ]\n ],\n - \ \"total\": 4,\n \"size\": 4,\n \"status\": 200\n}" - headers: - content-length: - - '1402' - content-type: - - application/json; charset=UTF-8 - date: - - Tue, 24 Sep 2024 22:18:26 GMT - server: - - aoss-amazon-s - x-envoy-upstream-service-time: - - '27' - x-request-id: - - 644a3560-53d4-49d0-a46c-959353a62724 - status: - code: 200 - message: OK -version: 1 diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index ff9faec..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - -""" -We can define the fixture functions in this file to make them -accessible across multiple test modules. -""" -import os -import pytest - -from .utils import create_index, delete_index, get_connection - - -@pytest.fixture(scope="function") -def connection(): - test_connection = get_connection() - create_index(test_connection) - - yield test_connection - delete_index(test_connection) - - -@pytest.fixture(scope="function") -def default_config_location(): - from src.opensearch_sql_cli.conf import __file__ as package_root - - package_root = os.path.dirname(package_root) - default_config = os.path.join(package_root, "clirc") - - yield default_config - - -@pytest.fixture(scope="session", autouse=True) -def temp_config(tmpdir_factory): - # this function runs on start of test session. - # use temporary directory for conf home so user conf will not be used - os.environ["XDG_CONFIG_HOME"] = str(tmpdir_factory.mktemp("data")) diff --git a/tests/pytest.ini b/tests/pytest.ini deleted file mode 100644 index f787740..0000000 --- a/tests/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -addopts=--capture=sys --showlocals \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 8541c71..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - -import os -import stat -import pytest - -from src.opensearch_sql_cli.config import ensure_dir_exists - - -class TestConfig: - def test_ensure_file_parent(self, tmpdir): - subdir = tmpdir.join("subdir") - rcfile = subdir.join("rcfile") - ensure_dir_exists(str(rcfile)) - - def test_ensure_existing_dir(self, tmpdir): - rcfile = str(tmpdir.mkdir("subdir").join("rcfile")) - - # should just not raise - ensure_dir_exists(rcfile) - - def test_ensure_other_create_error(self, tmpdir): - subdir = tmpdir.join("subdir") - rcfile = subdir.join("rcfile") - - # trigger an oserror that isn't "directory already exists" - os.chmod(str(tmpdir), stat.S_IREAD) - - with pytest.raises(OSError): - ensure_dir_exists(str(rcfile)) diff --git a/tests/test_data/accounts.json b/tests/test_data/accounts.json deleted file mode 100644 index 22254e3..0000000 --- a/tests/test_data/accounts.json +++ /dev/null @@ -1,1000 +0,0 @@ -{"account_number":1,"balance":39225,"firstname":"Amber","lastname":"Duke","age":32,"gender":"M","address":"880 Holmes Lane","employer":"Pyrami","email":"amberduke@pyrami.com","city":"Brogan","state":"IL"} -{"account_number":6,"balance":5686,"firstname":"Hattie","lastname":"Bond","age":36,"gender":"M","address":"671 Bristol Street","employer":"Netagy","email":"hattiebond@netagy.com","city":"Dante","state":"TN"} -{"account_number":13,"balance":32838,"firstname":"Nanette","lastname":"Bates","age":28,"gender":"F","address":"789 Madison Street","employer":"Quility","email":"nanettebates@quility.com","city":"Nogal","state":"VA"} -{"account_number":18,"balance":4180,"firstname":"Dale","lastname":"Adams","age":33,"gender":"M","address":"467 Hutchinson Court","employer":"Boink","email":"daleadams@boink.com","city":"Orick","state":"MD"} -{"account_number":20,"balance":16418,"firstname":"Elinor","lastname":"Ratliff","age":36,"gender":"M","address":"282 Kings Place","employer":"Scentric","email":"elinorratliff@scentric.com","city":"Ribera","state":"WA"} -{"account_number":25,"balance":40540,"firstname":"Virginia","lastname":"Ayala","age":39,"gender":"F","address":"171 Putnam Avenue","employer":"Filodyne","email":"virginiaayala@filodyne.com","city":"Nicholson","state":"PA"} -{"account_number":32,"balance":48086,"firstname":"Dillard","lastname":"Mcpherson","age":34,"gender":"F","address":"702 Quentin Street","employer":"Quailcom","email":"dillardmcpherson@quailcom.com","city":"Veguita","state":"IN"} -{"account_number":37,"balance":18612,"firstname":"Mcgee","lastname":"Mooney","age":39,"gender":"M","address":"826 Fillmore Place","employer":"Reversus","email":"mcgeemooney@reversus.com","city":"Tooleville","state":"OK"} -{"account_number":44,"balance":34487,"firstname":"Aurelia","lastname":"Harding","age":37,"gender":"M","address":"502 Baycliff Terrace","employer":"Orbalix","email":"aureliaharding@orbalix.com","city":"Yardville","state":"DE"} -{"account_number":49,"balance":29104,"firstname":"Fulton","lastname":"Holt","age":23,"gender":"F","address":"451 Humboldt Street","employer":"Anocha","email":"fultonholt@anocha.com","city":"Sunriver","state":"RI"} -{"account_number":51,"balance":14097,"firstname":"Burton","lastname":"Meyers","age":31,"gender":"F","address":"334 River Street","employer":"Bezal","email":"burtonmeyers@bezal.com","city":"Jacksonburg","state":"MO"} -{"account_number":56,"balance":14992,"firstname":"Josie","lastname":"Nelson","age":32,"gender":"M","address":"857 Tabor Court","employer":"Emtrac","email":"josienelson@emtrac.com","city":"Sunnyside","state":"UT"} -{"account_number":63,"balance":6077,"firstname":"Hughes","lastname":"Owens","age":30,"gender":"F","address":"510 Sedgwick Street","employer":"Valpreal","email":"hughesowens@valpreal.com","city":"Guilford","state":"KS"} -{"account_number":68,"balance":44214,"firstname":"Hall","lastname":"Key","age":25,"gender":"F","address":"927 Bay Parkway","employer":"Eventex","email":"hallkey@eventex.com","city":"Shawmut","state":"CA"} -{"account_number":70,"balance":38172,"firstname":"Deidre","lastname":"Thompson","age":33,"gender":"F","address":"685 School Lane","employer":"Netplode","email":"deidrethompson@netplode.com","city":"Chestnut","state":"GA"} -{"account_number":75,"balance":40500,"firstname":"Sandoval","lastname":"Kramer","age":22,"gender":"F","address":"166 Irvington Place","employer":"Overfork","email":"sandovalkramer@overfork.com","city":"Limestone","state":"NH"} -{"account_number":82,"balance":41412,"firstname":"Concetta","lastname":"Barnes","age":39,"gender":"F","address":"195 Bayview Place","employer":"Fitcore","email":"concettabarnes@fitcore.com","city":"Summerfield","state":"NC"} -{"account_number":87,"balance":1133,"firstname":"Hewitt","lastname":"Kidd","age":22,"gender":"M","address":"446 Halleck Street","employer":"Isologics","email":"hewittkidd@isologics.com","city":"Coalmont","state":"ME"} -{"account_number":94,"balance":41060,"firstname":"Brittany","lastname":"Cabrera","age":30,"gender":"F","address":"183 Kathleen Court","employer":"Mixers","email":"brittanycabrera@mixers.com","city":"Cornucopia","state":"AZ"} -{"account_number":99,"balance":47159,"firstname":"Ratliff","lastname":"Heath","age":39,"gender":"F","address":"806 Rockwell Place","employer":"Zappix","email":"ratliffheath@zappix.com","city":"Shaft","state":"ND"} -{"account_number":102,"balance":29712,"firstname":"Dena","lastname":"Olson","age":27,"gender":"F","address":"759 Newkirk Avenue","employer":"Hinway","email":"denaolson@hinway.com","city":"Choctaw","state":"NJ"} -{"account_number":107,"balance":48844,"firstname":"Randi","lastname":"Rich","age":28,"gender":"M","address":"694 Jefferson Street","employer":"Netplax","email":"randirich@netplax.com","city":"Bellfountain","state":"SC"} -{"account_number":114,"balance":43045,"firstname":"Josephine","lastname":"Joseph","age":31,"gender":"F","address":"451 Oriental Court","employer":"Turnabout","email":"josephinejoseph@turnabout.com","city":"Sedley","state":"AL"} -{"account_number":119,"balance":49222,"firstname":"Laverne","lastname":"Johnson","age":28,"gender":"F","address":"302 Howard Place","employer":"Senmei","email":"lavernejohnson@senmei.com","city":"Herlong","state":"DC"} -{"account_number":121,"balance":19594,"firstname":"Acevedo","lastname":"Dorsey","age":32,"gender":"M","address":"479 Nova Court","employer":"Netropic","email":"acevedodorsey@netropic.com","city":"Islandia","state":"CT"} -{"account_number":126,"balance":3607,"firstname":"Effie","lastname":"Gates","age":39,"gender":"F","address":"620 National Drive","employer":"Digitalus","email":"effiegates@digitalus.com","city":"Blodgett","state":"MD"} -{"account_number":133,"balance":26135,"firstname":"Deena","lastname":"Richmond","age":36,"gender":"F","address":"646 Underhill Avenue","employer":"Sunclipse","email":"deenarichmond@sunclipse.com","city":"Austinburg","state":"SC"} -{"account_number":138,"balance":9006,"firstname":"Daniel","lastname":"Arnold","age":39,"gender":"F","address":"422 Malbone Street","employer":"Ecstasia","email":"danielarnold@ecstasia.com","city":"Gardiner","state":"MO"} -{"account_number":140,"balance":26696,"firstname":"Cotton","lastname":"Christensen","age":32,"gender":"M","address":"878 Schermerhorn Street","employer":"Prowaste","email":"cottonchristensen@prowaste.com","city":"Mayfair","state":"LA"} -{"account_number":145,"balance":47406,"firstname":"Rowena","lastname":"Wilkinson","age":32,"gender":"M","address":"891 Elton Street","employer":"Asimiline","email":"rowenawilkinson@asimiline.com","city":"Ripley","state":"NH"} -{"account_number":152,"balance":8088,"firstname":"Wolfe","lastname":"Rocha","age":21,"gender":"M","address":"457 Guernsey Street","employer":"Hivedom","email":"wolferocha@hivedom.com","city":"Adelino","state":"MS"} -{"account_number":157,"balance":39868,"firstname":"Claudia","lastname":"Terry","age":20,"gender":"F","address":"132 Gunnison Court","employer":"Lumbrex","email":"claudiaterry@lumbrex.com","city":"Castleton","state":"MD"} -{"account_number":164,"balance":9101,"firstname":"Cummings","lastname":"Little","age":26,"gender":"F","address":"308 Schaefer Street","employer":"Comtrak","email":"cummingslittle@comtrak.com","city":"Chaparrito","state":"WI"} -{"account_number":169,"balance":45953,"firstname":"Hollie","lastname":"Osborn","age":34,"gender":"M","address":"671 Seaview Court","employer":"Musaphics","email":"hollieosborn@musaphics.com","city":"Hanover","state":"GA"} -{"account_number":171,"balance":7091,"firstname":"Nelda","lastname":"Hopper","age":39,"gender":"M","address":"742 Prospect Place","employer":"Equicom","email":"neldahopper@equicom.com","city":"Finderne","state":"SC"} -{"account_number":176,"balance":18607,"firstname":"Kemp","lastname":"Walters","age":28,"gender":"F","address":"906 Howard Avenue","employer":"Eyewax","email":"kempwalters@eyewax.com","city":"Why","state":"KY"} -{"account_number":183,"balance":14223,"firstname":"Hudson","lastname":"English","age":26,"gender":"F","address":"823 Herkimer Place","employer":"Xinware","email":"hudsonenglish@xinware.com","city":"Robbins","state":"ND"} -{"account_number":188,"balance":41504,"firstname":"Tia","lastname":"Miranda","age":24,"gender":"F","address":"583 Ainslie Street","employer":"Jasper","email":"tiamiranda@jasper.com","city":"Summerset","state":"UT"} -{"account_number":190,"balance":3150,"firstname":"Blake","lastname":"Davidson","age":30,"gender":"F","address":"636 Diamond Street","employer":"Quantasis","email":"blakedavidson@quantasis.com","city":"Crumpler","state":"KY"} -{"account_number":195,"balance":5025,"firstname":"Kaye","lastname":"Gibson","age":31,"gender":"M","address":"955 Hopkins Street","employer":"Zork","email":"kayegibson@zork.com","city":"Ola","state":"WY"} -{"account_number":203,"balance":21890,"firstname":"Eve","lastname":"Wyatt","age":33,"gender":"M","address":"435 Furman Street","employer":"Assitia","email":"evewyatt@assitia.com","city":"Jamestown","state":"MN"} -{"account_number":208,"balance":40760,"firstname":"Garcia","lastname":"Hess","age":26,"gender":"F","address":"810 Nostrand Avenue","employer":"Quiltigen","email":"garciahess@quiltigen.com","city":"Brooktrails","state":"GA"} -{"account_number":210,"balance":33946,"firstname":"Cherry","lastname":"Carey","age":24,"gender":"M","address":"539 Tiffany Place","employer":"Martgo","email":"cherrycarey@martgo.com","city":"Fairacres","state":"AK"} -{"account_number":215,"balance":37427,"firstname":"Copeland","lastname":"Solomon","age":20,"gender":"M","address":"741 McDonald Avenue","employer":"Recognia","email":"copelandsolomon@recognia.com","city":"Edmund","state":"ME"} -{"account_number":222,"balance":14764,"firstname":"Rachelle","lastname":"Rice","age":36,"gender":"M","address":"333 Narrows Avenue","employer":"Enaut","email":"rachellerice@enaut.com","city":"Wright","state":"AZ"} -{"account_number":227,"balance":19780,"firstname":"Coleman","lastname":"Berg","age":22,"gender":"M","address":"776 Little Street","employer":"Exoteric","email":"colemanberg@exoteric.com","city":"Eagleville","state":"WV"} -{"account_number":234,"balance":44207,"firstname":"Betty","lastname":"Hall","age":37,"gender":"F","address":"709 Garfield Place","employer":"Miraclis","email":"bettyhall@miraclis.com","city":"Bendon","state":"NY"} -{"account_number":239,"balance":25719,"firstname":"Chang","lastname":"Boyer","age":36,"gender":"M","address":"895 Brigham Street","employer":"Qaboos","email":"changboyer@qaboos.com","city":"Belgreen","state":"NH"} -{"account_number":241,"balance":25379,"firstname":"Schroeder","lastname":"Harrington","age":26,"gender":"M","address":"610 Tapscott Avenue","employer":"Otherway","email":"schroederharrington@otherway.com","city":"Ebro","state":"TX"} -{"account_number":246,"balance":28405,"firstname":"Katheryn","lastname":"Foster","age":21,"gender":"F","address":"259 Kane Street","employer":"Quantalia","email":"katherynfoster@quantalia.com","city":"Bath","state":"TX"} -{"account_number":253,"balance":20240,"firstname":"Melissa","lastname":"Gould","age":31,"gender":"M","address":"440 Fuller Place","employer":"Buzzopia","email":"melissagould@buzzopia.com","city":"Lumberton","state":"MD"} -{"account_number":258,"balance":5712,"firstname":"Lindsey","lastname":"Hawkins","age":37,"gender":"M","address":"706 Frost Street","employer":"Enormo","email":"lindseyhawkins@enormo.com","city":"Gardners","state":"AK"} -{"account_number":260,"balance":2726,"firstname":"Kari","lastname":"Skinner","age":30,"gender":"F","address":"735 Losee Terrace","employer":"Singavera","email":"kariskinner@singavera.com","city":"Rushford","state":"WV"} -{"account_number":265,"balance":46910,"firstname":"Marion","lastname":"Schneider","age":26,"gender":"F","address":"574 Everett Avenue","employer":"Evidends","email":"marionschneider@evidends.com","city":"Maplewood","state":"WY"} -{"account_number":272,"balance":19253,"firstname":"Lilly","lastname":"Morgan","age":25,"gender":"F","address":"689 Fleet Street","employer":"Biolive","email":"lillymorgan@biolive.com","city":"Sunbury","state":"OH"} -{"account_number":277,"balance":29564,"firstname":"Romero","lastname":"Lott","age":31,"gender":"M","address":"456 Danforth Street","employer":"Plasto","email":"romerolott@plasto.com","city":"Vincent","state":"VT"} -{"account_number":284,"balance":22806,"firstname":"Randolph","lastname":"Banks","age":29,"gender":"M","address":"875 Hamilton Avenue","employer":"Caxt","email":"randolphbanks@caxt.com","city":"Crawfordsville","state":"WA"} -{"account_number":289,"balance":7798,"firstname":"Blair","lastname":"Church","age":29,"gender":"M","address":"370 Sutton Street","employer":"Cubix","email":"blairchurch@cubix.com","city":"Nile","state":"NH"} -{"account_number":291,"balance":19955,"firstname":"Lynn","lastname":"Pollard","age":40,"gender":"F","address":"685 Pierrepont Street","employer":"Slambda","email":"lynnpollard@slambda.com","city":"Mappsville","state":"ID"} -{"account_number":296,"balance":24606,"firstname":"Rosa","lastname":"Oliver","age":34,"gender":"M","address":"168 Woodbine Street","employer":"Idetica","email":"rosaoliver@idetica.com","city":"Robinson","state":"WY"} -{"account_number":304,"balance":28647,"firstname":"Palmer","lastname":"Clark","age":35,"gender":"M","address":"866 Boulevard Court","employer":"Maximind","email":"palmerclark@maximind.com","city":"Avalon","state":"NH"} -{"account_number":309,"balance":3830,"firstname":"Rosemarie","lastname":"Nieves","age":30,"gender":"M","address":"206 Alice Court","employer":"Zounds","email":"rosemarienieves@zounds.com","city":"Ferney","state":"AR"} -{"account_number":311,"balance":13388,"firstname":"Vinson","lastname":"Ballard","age":23,"gender":"F","address":"960 Glendale Court","employer":"Gynk","email":"vinsonballard@gynk.com","city":"Fairforest","state":"WY"} -{"account_number":316,"balance":8214,"firstname":"Anita","lastname":"Ewing","age":32,"gender":"M","address":"396 Lombardy Street","employer":"Panzent","email":"anitaewing@panzent.com","city":"Neahkahnie","state":"WY"} -{"account_number":323,"balance":42230,"firstname":"Chelsea","lastname":"Gamble","age":34,"gender":"F","address":"356 Dare Court","employer":"Isosphere","email":"chelseagamble@isosphere.com","city":"Dundee","state":"MD"} -{"account_number":328,"balance":12523,"firstname":"Good","lastname":"Campbell","age":27,"gender":"F","address":"438 Hicks Street","employer":"Gracker","email":"goodcampbell@gracker.com","city":"Marion","state":"CA"} -{"account_number":330,"balance":41620,"firstname":"Yvette","lastname":"Browning","age":34,"gender":"F","address":"431 Beekman Place","employer":"Marketoid","email":"yvettebrowning@marketoid.com","city":"Talpa","state":"CO"} -{"account_number":335,"balance":35433,"firstname":"Vera","lastname":"Hansen","age":24,"gender":"M","address":"252 Bushwick Avenue","employer":"Zanilla","email":"verahansen@zanilla.com","city":"Manila","state":"TN"} -{"account_number":342,"balance":33670,"firstname":"Vivian","lastname":"Wells","age":36,"gender":"M","address":"570 Cobek Court","employer":"Nutralab","email":"vivianwells@nutralab.com","city":"Fontanelle","state":"OK"} -{"account_number":347,"balance":36038,"firstname":"Gould","lastname":"Carson","age":24,"gender":"F","address":"784 Pulaski Street","employer":"Mobildata","email":"gouldcarson@mobildata.com","city":"Goochland","state":"MI"} -{"account_number":354,"balance":21294,"firstname":"Kidd","lastname":"Mclean","age":22,"gender":"M","address":"691 Saratoga Avenue","employer":"Ronbert","email":"kiddmclean@ronbert.com","city":"Tioga","state":"ME"} -{"account_number":359,"balance":29927,"firstname":"Vanessa","lastname":"Harvey","age":28,"gender":"F","address":"679 Rutledge Street","employer":"Zentime","email":"vanessaharvey@zentime.com","city":"Williston","state":"IL"} -{"account_number":361,"balance":23659,"firstname":"Noreen","lastname":"Shelton","age":36,"gender":"M","address":"702 Tillary Street","employer":"Medmex","email":"noreenshelton@medmex.com","city":"Derwood","state":"NH"} -{"account_number":366,"balance":42368,"firstname":"Lydia","lastname":"Cooke","age":31,"gender":"M","address":"470 Coleman Street","employer":"Comstar","email":"lydiacooke@comstar.com","city":"Datil","state":"TN"} -{"account_number":373,"balance":9671,"firstname":"Simpson","lastname":"Carpenter","age":21,"gender":"M","address":"837 Horace Court","employer":"Snips","email":"simpsoncarpenter@snips.com","city":"Tolu","state":"MA"} -{"account_number":378,"balance":27100,"firstname":"Watson","lastname":"Simpson","age":36,"gender":"F","address":"644 Thomas Street","employer":"Wrapture","email":"watsonsimpson@wrapture.com","city":"Keller","state":"TX"} -{"account_number":380,"balance":35628,"firstname":"Fernandez","lastname":"Reid","age":33,"gender":"F","address":"154 Melba Court","employer":"Cosmosis","email":"fernandezreid@cosmosis.com","city":"Boyd","state":"NE"} -{"account_number":385,"balance":11022,"firstname":"Rosalinda","lastname":"Valencia","age":22,"gender":"M","address":"933 Lloyd Street","employer":"Zoarere","email":"rosalindavalencia@zoarere.com","city":"Waverly","state":"GA"} -{"account_number":392,"balance":31613,"firstname":"Dotson","lastname":"Dean","age":35,"gender":"M","address":"136 Ford Street","employer":"Petigems","email":"dotsondean@petigems.com","city":"Chical","state":"SD"} -{"account_number":397,"balance":37418,"firstname":"Leonard","lastname":"Gray","age":36,"gender":"F","address":"840 Morgan Avenue","employer":"Recritube","email":"leonardgray@recritube.com","city":"Edenburg","state":"AL"} -{"account_number":400,"balance":20685,"firstname":"Kane","lastname":"King","age":21,"gender":"F","address":"405 Cornelia Street","employer":"Tri@Tribalog","email":"kaneking@tri@tribalog.com","city":"Gulf","state":"VT"} -{"account_number":405,"balance":5679,"firstname":"Strickland","lastname":"Fuller","age":26,"gender":"M","address":"990 Concord Street","employer":"Digique","email":"stricklandfuller@digique.com","city":"Southmont","state":"NV"} -{"account_number":412,"balance":27436,"firstname":"Ilene","lastname":"Abbott","age":26,"gender":"M","address":"846 Vine Street","employer":"Typhonica","email":"ileneabbott@typhonica.com","city":"Cedarville","state":"VT"} -{"account_number":417,"balance":1788,"firstname":"Wheeler","lastname":"Ayers","age":35,"gender":"F","address":"677 Hope Street","employer":"Fortean","email":"wheelerayers@fortean.com","city":"Ironton","state":"PA"} -{"account_number":424,"balance":36818,"firstname":"Tracie","lastname":"Gregory","age":34,"gender":"M","address":"112 Hunterfly Place","employer":"Comstruct","email":"traciegregory@comstruct.com","city":"Onton","state":"TN"} -{"account_number":429,"balance":46970,"firstname":"Cantu","lastname":"Lindsey","age":31,"gender":"M","address":"404 Willoughby Avenue","employer":"Inquala","email":"cantulindsey@inquala.com","city":"Cowiche","state":"IA"} -{"account_number":431,"balance":13136,"firstname":"Laurie","lastname":"Shaw","age":26,"gender":"F","address":"263 Aviation Road","employer":"Zillanet","email":"laurieshaw@zillanet.com","city":"Harmon","state":"WV"} -{"account_number":436,"balance":27585,"firstname":"Alexander","lastname":"Sargent","age":23,"gender":"M","address":"363 Albemarle Road","employer":"Fangold","email":"alexandersargent@fangold.com","city":"Calpine","state":"OR"} -{"account_number":443,"balance":7588,"firstname":"Huff","lastname":"Thomas","age":23,"gender":"M","address":"538 Erskine Loop","employer":"Accufarm","email":"huffthomas@accufarm.com","city":"Corinne","state":"AL"} -{"account_number":448,"balance":22776,"firstname":"Adriana","lastname":"Mcfadden","age":35,"gender":"F","address":"984 Woodside Avenue","employer":"Telequiet","email":"adrianamcfadden@telequiet.com","city":"Darrtown","state":"WI"} -{"account_number":450,"balance":2643,"firstname":"Bradford","lastname":"Nielsen","age":25,"gender":"M","address":"487 Keen Court","employer":"Exovent","email":"bradfordnielsen@exovent.com","city":"Hamilton","state":"DE"} -{"account_number":455,"balance":39556,"firstname":"Lynn","lastname":"Tran","age":36,"gender":"M","address":"741 Richmond Street","employer":"Optyk","email":"lynntran@optyk.com","city":"Clinton","state":"WV"} -{"account_number":462,"balance":10871,"firstname":"Calderon","lastname":"Day","age":27,"gender":"M","address":"810 Milford Street","employer":"Cofine","email":"calderonday@cofine.com","city":"Kula","state":"OK"} -{"account_number":467,"balance":6312,"firstname":"Angelica","lastname":"May","age":32,"gender":"F","address":"384 Karweg Place","employer":"Keeg","email":"angelicamay@keeg.com","city":"Tetherow","state":"IA"} -{"account_number":474,"balance":35896,"firstname":"Obrien","lastname":"Walton","age":40,"gender":"F","address":"192 Ide Court","employer":"Suremax","email":"obrienwalton@suremax.com","city":"Crucible","state":"UT"} -{"account_number":479,"balance":31865,"firstname":"Cameron","lastname":"Ross","age":40,"gender":"M","address":"904 Bouck Court","employer":"Telpod","email":"cameronross@telpod.com","city":"Nord","state":"MO"} -{"account_number":481,"balance":20024,"firstname":"Lina","lastname":"Stanley","age":33,"gender":"M","address":"361 Hanover Place","employer":"Strozen","email":"linastanley@strozen.com","city":"Wyoming","state":"NC"} -{"account_number":486,"balance":35902,"firstname":"Dixie","lastname":"Fuentes","age":22,"gender":"F","address":"991 Applegate Court","employer":"Portico","email":"dixiefuentes@portico.com","city":"Salix","state":"VA"} -{"account_number":493,"balance":5871,"firstname":"Campbell","lastname":"Best","age":24,"gender":"M","address":"297 Friel Place","employer":"Fanfare","email":"campbellbest@fanfare.com","city":"Kidder","state":"GA"} -{"account_number":498,"balance":10516,"firstname":"Stella","lastname":"Hinton","age":39,"gender":"F","address":"649 Columbia Place","employer":"Flyboyz","email":"stellahinton@flyboyz.com","city":"Crenshaw","state":"SC"} -{"account_number":501,"balance":16572,"firstname":"Kelley","lastname":"Ochoa","age":36,"gender":"M","address":"451 Clifton Place","employer":"Bluplanet","email":"kelleyochoa@bluplanet.com","city":"Gouglersville","state":"CT"} -{"account_number":506,"balance":43440,"firstname":"Davidson","lastname":"Salas","age":28,"gender":"M","address":"731 Cleveland Street","employer":"Sequitur","email":"davidsonsalas@sequitur.com","city":"Lloyd","state":"ME"} -{"account_number":513,"balance":30040,"firstname":"Maryellen","lastname":"Rose","age":37,"gender":"F","address":"428 Durland Place","employer":"Waterbaby","email":"maryellenrose@waterbaby.com","city":"Kiskimere","state":"RI"} -{"account_number":518,"balance":48954,"firstname":"Finch","lastname":"Curtis","age":29,"gender":"F","address":"137 Ryder Street","employer":"Viagrand","email":"finchcurtis@viagrand.com","city":"Riverton","state":"MO"} -{"account_number":520,"balance":27987,"firstname":"Brandy","lastname":"Calhoun","age":32,"gender":"M","address":"818 Harden Street","employer":"Maxemia","email":"brandycalhoun@maxemia.com","city":"Sidman","state":"OR"} -{"account_number":525,"balance":23545,"firstname":"Holly","lastname":"Miles","age":25,"gender":"M","address":"746 Ludlam Place","employer":"Xurban","email":"hollymiles@xurban.com","city":"Harold","state":"AR"} -{"account_number":532,"balance":17207,"firstname":"Hardin","lastname":"Kirk","age":26,"gender":"M","address":"268 Canarsie Road","employer":"Exposa","email":"hardinkirk@exposa.com","city":"Stouchsburg","state":"IL"} -{"account_number":537,"balance":31069,"firstname":"Morin","lastname":"Frost","age":29,"gender":"M","address":"910 Lake Street","employer":"Primordia","email":"morinfrost@primordia.com","city":"Rivera","state":"DE"} -{"account_number":544,"balance":41735,"firstname":"Short","lastname":"Dennis","age":21,"gender":"F","address":"908 Glen Street","employer":"Minga","email":"shortdennis@minga.com","city":"Dale","state":"KY"} -{"account_number":549,"balance":1932,"firstname":"Jacqueline","lastname":"Maxwell","age":40,"gender":"M","address":"444 Schenck Place","employer":"Fuelworks","email":"jacquelinemaxwell@fuelworks.com","city":"Oretta","state":"OR"} -{"account_number":551,"balance":21732,"firstname":"Milagros","lastname":"Travis","age":27,"gender":"F","address":"380 Murdock Court","employer":"Sloganaut","email":"milagrostravis@sloganaut.com","city":"Homeland","state":"AR"} -{"account_number":556,"balance":36420,"firstname":"Collier","lastname":"Odonnell","age":35,"gender":"M","address":"591 Nolans Lane","employer":"Sultraxin","email":"collierodonnell@sultraxin.com","city":"Fulford","state":"MD"} -{"account_number":563,"balance":43403,"firstname":"Morgan","lastname":"Torres","age":30,"gender":"F","address":"672 Belvidere Street","employer":"Quonata","email":"morgantorres@quonata.com","city":"Hollymead","state":"KY"} -{"account_number":568,"balance":36628,"firstname":"Lesa","lastname":"Maynard","age":29,"gender":"F","address":"295 Whitty Lane","employer":"Coash","email":"lesamaynard@coash.com","city":"Broadlands","state":"VT"} -{"account_number":570,"balance":26751,"firstname":"Church","lastname":"Mercado","age":24,"gender":"F","address":"892 Wyckoff Street","employer":"Xymonk","email":"churchmercado@xymonk.com","city":"Gloucester","state":"KY"} -{"account_number":575,"balance":12588,"firstname":"Buchanan","lastname":"Pope","age":39,"gender":"M","address":"581 Sumner Place","employer":"Stucco","email":"buchananpope@stucco.com","city":"Ellerslie","state":"MD"} -{"account_number":582,"balance":33371,"firstname":"Manning","lastname":"Guthrie","age":24,"gender":"F","address":"271 Jodie Court","employer":"Xerex","email":"manningguthrie@xerex.com","city":"Breinigsville","state":"NM"} -{"account_number":587,"balance":3468,"firstname":"Carly","lastname":"Johns","age":33,"gender":"M","address":"390 Noll Street","employer":"Gallaxia","email":"carlyjohns@gallaxia.com","city":"Emison","state":"DC"} -{"account_number":594,"balance":28194,"firstname":"Golden","lastname":"Donovan","age":26,"gender":"M","address":"199 Jewel Street","employer":"Organica","email":"goldendonovan@organica.com","city":"Macdona","state":"RI"} -{"account_number":599,"balance":11944,"firstname":"Joanna","lastname":"Jennings","age":36,"gender":"F","address":"318 Irving Street","employer":"Extremo","email":"joannajennings@extremo.com","city":"Bartley","state":"MI"} -{"account_number":602,"balance":38699,"firstname":"Mcgowan","lastname":"Mcclain","age":33,"gender":"M","address":"361 Stoddard Place","employer":"Oatfarm","email":"mcgowanmcclain@oatfarm.com","city":"Kapowsin","state":"MI"} -{"account_number":607,"balance":38350,"firstname":"White","lastname":"Small","age":38,"gender":"F","address":"736 Judge Street","employer":"Immunics","email":"whitesmall@immunics.com","city":"Fairfield","state":"HI"} -{"account_number":614,"balance":13157,"firstname":"Salazar","lastname":"Howard","age":35,"gender":"F","address":"847 Imlay Street","employer":"Retrack","email":"salazarhoward@retrack.com","city":"Grill","state":"FL"} -{"account_number":619,"balance":48755,"firstname":"Grimes","lastname":"Reynolds","age":36,"gender":"M","address":"378 Denton Place","employer":"Frenex","email":"grimesreynolds@frenex.com","city":"Murillo","state":"LA"} -{"account_number":621,"balance":35480,"firstname":"Leslie","lastname":"Sloan","age":26,"gender":"F","address":"336 Kansas Place","employer":"Dancity","email":"lesliesloan@dancity.com","city":"Corriganville","state":"AR"} -{"account_number":626,"balance":19498,"firstname":"Ava","lastname":"Richardson","age":31,"gender":"F","address":"666 Nautilus Avenue","employer":"Cinaster","email":"avarichardson@cinaster.com","city":"Sutton","state":"AL"} -{"account_number":633,"balance":35874,"firstname":"Conner","lastname":"Ramos","age":34,"gender":"M","address":"575 Agate Court","employer":"Insource","email":"connerramos@insource.com","city":"Madaket","state":"OK"} -{"account_number":638,"balance":2658,"firstname":"Bridget","lastname":"Gallegos","age":31,"gender":"M","address":"383 Wogan Terrace","employer":"Songlines","email":"bridgetgallegos@songlines.com","city":"Linganore","state":"WA"} -{"account_number":640,"balance":35596,"firstname":"Candace","lastname":"Hancock","age":25,"gender":"M","address":"574 Riverdale Avenue","employer":"Animalia","email":"candacehancock@animalia.com","city":"Blandburg","state":"KY"} -{"account_number":645,"balance":29362,"firstname":"Edwina","lastname":"Hutchinson","age":26,"gender":"F","address":"892 Pacific Street","employer":"Essensia","email":"edwinahutchinson@essensia.com","city":"Dowling","state":"NE"} -{"account_number":652,"balance":17363,"firstname":"Bonner","lastname":"Garner","age":26,"gender":"M","address":"219 Grafton Street","employer":"Utarian","email":"bonnergarner@utarian.com","city":"Vandiver","state":"PA"} -{"account_number":657,"balance":40475,"firstname":"Kathleen","lastname":"Wilder","age":34,"gender":"F","address":"286 Sutter Avenue","employer":"Solgan","email":"kathleenwilder@solgan.com","city":"Graniteville","state":"MI"} -{"account_number":664,"balance":16163,"firstname":"Hart","lastname":"Mccormick","age":40,"gender":"M","address":"144 Guider Avenue","employer":"Dyno","email":"hartmccormick@dyno.com","city":"Carbonville","state":"ID"} -{"account_number":669,"balance":16934,"firstname":"Jewel","lastname":"Estrada","age":28,"gender":"M","address":"896 Meeker Avenue","employer":"Zilla","email":"jewelestrada@zilla.com","city":"Goodville","state":"PA"} -{"account_number":671,"balance":29029,"firstname":"Antoinette","lastname":"Cook","age":34,"gender":"M","address":"375 Cumberland Street","employer":"Harmoney","email":"antoinettecook@harmoney.com","city":"Bergoo","state":"VT"} -{"account_number":676,"balance":23842,"firstname":"Lisa","lastname":"Dudley","age":34,"gender":"M","address":"506 Vanderveer Street","employer":"Tropoli","email":"lisadudley@tropoli.com","city":"Konterra","state":"NY"} -{"account_number":683,"balance":4381,"firstname":"Matilda","lastname":"Berger","age":39,"gender":"M","address":"884 Noble Street","employer":"Fibrodyne","email":"matildaberger@fibrodyne.com","city":"Shepardsville","state":"TN"} -{"account_number":688,"balance":17931,"firstname":"Freeman","lastname":"Zamora","age":22,"gender":"F","address":"114 Herzl Street","employer":"Elemantra","email":"freemanzamora@elemantra.com","city":"Libertytown","state":"NM"} -{"account_number":690,"balance":18127,"firstname":"Russo","lastname":"Swanson","age":35,"gender":"F","address":"256 Roebling Street","employer":"Zaj","email":"russoswanson@zaj.com","city":"Hoagland","state":"MI"} -{"account_number":695,"balance":36800,"firstname":"Gonzales","lastname":"Mcfarland","age":26,"gender":"F","address":"647 Louisa Street","employer":"Songbird","email":"gonzalesmcfarland@songbird.com","city":"Crisman","state":"ID"} -{"account_number":703,"balance":27443,"firstname":"Dona","lastname":"Burton","age":29,"gender":"M","address":"489 Flatlands Avenue","employer":"Cytrex","email":"donaburton@cytrex.com","city":"Reno","state":"VA"} -{"account_number":708,"balance":34002,"firstname":"May","lastname":"Ortiz","age":28,"gender":"F","address":"244 Chauncey Street","employer":"Syntac","email":"mayortiz@syntac.com","city":"Munjor","state":"ID"} -{"account_number":710,"balance":33650,"firstname":"Shelton","lastname":"Stark","age":37,"gender":"M","address":"404 Ovington Avenue","employer":"Kraggle","email":"sheltonstark@kraggle.com","city":"Ogema","state":"TN"} -{"account_number":715,"balance":23734,"firstname":"Tammi","lastname":"Hodge","age":24,"gender":"M","address":"865 Church Lane","employer":"Netur","email":"tammihodge@netur.com","city":"Lacomb","state":"KS"} -{"account_number":722,"balance":27256,"firstname":"Roberts","lastname":"Beasley","age":34,"gender":"F","address":"305 Kings Hwy","employer":"Quintity","email":"robertsbeasley@quintity.com","city":"Hayden","state":"PA"} -{"account_number":727,"balance":27263,"firstname":"Natasha","lastname":"Knapp","age":36,"gender":"M","address":"723 Hubbard Street","employer":"Exostream","email":"natashaknapp@exostream.com","city":"Trexlertown","state":"LA"} -{"account_number":734,"balance":20325,"firstname":"Keri","lastname":"Kinney","age":23,"gender":"M","address":"490 Balfour Place","employer":"Retrotex","email":"kerikinney@retrotex.com","city":"Salunga","state":"PA"} -{"account_number":739,"balance":39063,"firstname":"Gwen","lastname":"Hardy","age":33,"gender":"F","address":"733 Stuart Street","employer":"Exozent","email":"gwenhardy@exozent.com","city":"Drytown","state":"NY"} -{"account_number":741,"balance":33074,"firstname":"Nielsen","lastname":"Good","age":22,"gender":"M","address":"404 Norfolk Street","employer":"Kiggle","email":"nielsengood@kiggle.com","city":"Cumberland","state":"WA"} -{"account_number":746,"balance":15970,"firstname":"Marguerite","lastname":"Wall","age":28,"gender":"F","address":"364 Crosby Avenue","employer":"Aquoavo","email":"margueritewall@aquoavo.com","city":"Jeff","state":"MI"} -{"account_number":753,"balance":33340,"firstname":"Katina","lastname":"Alford","age":21,"gender":"F","address":"690 Ross Street","employer":"Intrawear","email":"katinaalford@intrawear.com","city":"Grimsley","state":"OK"} -{"account_number":758,"balance":15739,"firstname":"Berta","lastname":"Short","age":28,"gender":"M","address":"149 Surf Avenue","employer":"Ozean","email":"bertashort@ozean.com","city":"Odessa","state":"UT"} -{"account_number":760,"balance":40996,"firstname":"Rhea","lastname":"Blair","age":37,"gender":"F","address":"440 Hubbard Place","employer":"Bicol","email":"rheablair@bicol.com","city":"Stockwell","state":"LA"} -{"account_number":765,"balance":31278,"firstname":"Knowles","lastname":"Cunningham","age":23,"gender":"M","address":"753 Macdougal Street","employer":"Thredz","email":"knowlescunningham@thredz.com","city":"Thomasville","state":"WA"} -{"account_number":772,"balance":37849,"firstname":"Eloise","lastname":"Sparks","age":21,"gender":"M","address":"608 Willow Street","employer":"Satiance","email":"eloisesparks@satiance.com","city":"Richford","state":"NY"} -{"account_number":777,"balance":48294,"firstname":"Adkins","lastname":"Mejia","age":32,"gender":"M","address":"186 Oxford Walk","employer":"Datagen","email":"adkinsmejia@datagen.com","city":"Faywood","state":"OK"} -{"account_number":784,"balance":25291,"firstname":"Mabel","lastname":"Thornton","age":21,"gender":"M","address":"124 Louisiana Avenue","employer":"Zolavo","email":"mabelthornton@zolavo.com","city":"Lynn","state":"AL"} -{"account_number":789,"balance":8760,"firstname":"Cunningham","lastname":"Kerr","age":27,"gender":"F","address":"154 Sharon Street","employer":"Polarium","email":"cunninghamkerr@polarium.com","city":"Tuskahoma","state":"MS"} -{"account_number":791,"balance":48249,"firstname":"Janine","lastname":"Huber","age":38,"gender":"F","address":"348 Porter Avenue","employer":"Viocular","email":"janinehuber@viocular.com","city":"Fivepointville","state":"MA"} -{"account_number":796,"balance":23503,"firstname":"Mona","lastname":"Craft","age":35,"gender":"F","address":"511 Henry Street","employer":"Opticom","email":"monacraft@opticom.com","city":"Websterville","state":"IN"} -{"account_number":804,"balance":23610,"firstname":"Rojas","lastname":"Oneal","age":27,"gender":"M","address":"669 Sandford Street","employer":"Glukgluk","email":"rojasoneal@glukgluk.com","city":"Wheaton","state":"ME"} -{"account_number":809,"balance":47812,"firstname":"Christie","lastname":"Strickland","age":30,"gender":"M","address":"346 Bancroft Place","employer":"Anarco","email":"christiestrickland@anarco.com","city":"Baden","state":"NV"} -{"account_number":811,"balance":26007,"firstname":"Walls","lastname":"Rogers","age":28,"gender":"F","address":"352 Freeman Street","employer":"Geekmosis","email":"wallsrogers@geekmosis.com","city":"Caroleen","state":"NV"} -{"account_number":816,"balance":9567,"firstname":"Cornelia","lastname":"Lane","age":20,"gender":"F","address":"384 Bainbridge Street","employer":"Sulfax","email":"cornelialane@sulfax.com","city":"Elizaville","state":"MS"} -{"account_number":823,"balance":48726,"firstname":"Celia","lastname":"Bernard","age":33,"gender":"F","address":"466 Amboy Street","employer":"Mitroc","email":"celiabernard@mitroc.com","city":"Skyland","state":"GA"} -{"account_number":828,"balance":44890,"firstname":"Blanche","lastname":"Holmes","age":33,"gender":"F","address":"605 Stryker Court","employer":"Motovate","email":"blancheholmes@motovate.com","city":"Loomis","state":"KS"} -{"account_number":830,"balance":45210,"firstname":"Louella","lastname":"Chan","age":23,"gender":"M","address":"511 Heath Place","employer":"Conferia","email":"louellachan@conferia.com","city":"Brookfield","state":"OK"} -{"account_number":835,"balance":46558,"firstname":"Glover","lastname":"Rutledge","age":25,"gender":"F","address":"641 Royce Street","employer":"Ginkogene","email":"gloverrutledge@ginkogene.com","city":"Dixonville","state":"VA"} -{"account_number":842,"balance":49587,"firstname":"Meagan","lastname":"Buckner","age":23,"gender":"F","address":"833 Bushwick Court","employer":"Biospan","email":"meaganbuckner@biospan.com","city":"Craig","state":"TX"} -{"account_number":847,"balance":8652,"firstname":"Antonia","lastname":"Duncan","age":23,"gender":"M","address":"644 Stryker Street","employer":"Talae","email":"antoniaduncan@talae.com","city":"Dawn","state":"MO"} -{"account_number":854,"balance":49795,"firstname":"Jimenez","lastname":"Barry","age":25,"gender":"F","address":"603 Cooper Street","employer":"Verton","email":"jimenezbarry@verton.com","city":"Moscow","state":"AL"} -{"account_number":859,"balance":20734,"firstname":"Beulah","lastname":"Stuart","age":24,"gender":"F","address":"651 Albemarle Terrace","employer":"Hatology","email":"beulahstuart@hatology.com","city":"Waiohinu","state":"RI"} -{"account_number":861,"balance":44173,"firstname":"Jaime","lastname":"Wilson","age":35,"gender":"M","address":"680 Richardson Street","employer":"Temorak","email":"jaimewilson@temorak.com","city":"Fidelis","state":"FL"} -{"account_number":866,"balance":45565,"firstname":"Araceli","lastname":"Woodward","age":28,"gender":"M","address":"326 Meadow Street","employer":"Olympix","email":"araceliwoodward@olympix.com","city":"Dana","state":"KS"} -{"account_number":873,"balance":43931,"firstname":"Tisha","lastname":"Cotton","age":39,"gender":"F","address":"432 Lincoln Road","employer":"Buzzmaker","email":"tishacotton@buzzmaker.com","city":"Bluetown","state":"GA"} -{"account_number":878,"balance":49159,"firstname":"Battle","lastname":"Blackburn","age":40,"gender":"F","address":"234 Hendrix Street","employer":"Zilphur","email":"battleblackburn@zilphur.com","city":"Wanamie","state":"PA"} -{"account_number":880,"balance":22575,"firstname":"Christian","lastname":"Myers","age":35,"gender":"M","address":"737 Crown Street","employer":"Combogen","email":"christianmyers@combogen.com","city":"Abrams","state":"OK"} -{"account_number":885,"balance":31661,"firstname":"Valdez","lastname":"Roberson","age":40,"gender":"F","address":"227 Scholes Street","employer":"Delphide","email":"valdezroberson@delphide.com","city":"Chilton","state":"MT"} -{"account_number":892,"balance":44974,"firstname":"Hill","lastname":"Hayes","age":29,"gender":"M","address":"721 Dooley Street","employer":"Fuelton","email":"hillhayes@fuelton.com","city":"Orason","state":"MT"} -{"account_number":897,"balance":45973,"firstname":"Alyson","lastname":"Irwin","age":25,"gender":"M","address":"731 Poplar Street","employer":"Quizka","email":"alysonirwin@quizka.com","city":"Singer","state":"VA"} -{"account_number":900,"balance":6124,"firstname":"Gonzalez","lastname":"Watson","age":23,"gender":"M","address":"624 Sullivan Street","employer":"Marvane","email":"gonzalezwatson@marvane.com","city":"Wikieup","state":"IL"} -{"account_number":905,"balance":29438,"firstname":"Schultz","lastname":"Moreno","age":20,"gender":"F","address":"761 Cedar Street","employer":"Paragonia","email":"schultzmoreno@paragonia.com","city":"Glenshaw","state":"SC"} -{"account_number":912,"balance":13675,"firstname":"Flora","lastname":"Alvarado","age":26,"gender":"M","address":"771 Vandervoort Avenue","employer":"Boilicon","email":"floraalvarado@boilicon.com","city":"Vivian","state":"ID"} -{"account_number":917,"balance":47782,"firstname":"Parks","lastname":"Hurst","age":24,"gender":"M","address":"933 Cozine Avenue","employer":"Pyramis","email":"parkshurst@pyramis.com","city":"Lindcove","state":"GA"} -{"account_number":924,"balance":3811,"firstname":"Hilary","lastname":"Leonard","age":24,"gender":"M","address":"235 Hegeman Avenue","employer":"Metroz","email":"hilaryleonard@metroz.com","city":"Roosevelt","state":"ME"} -{"account_number":929,"balance":34708,"firstname":"Willie","lastname":"Hickman","age":35,"gender":"M","address":"430 Devoe Street","employer":"Apextri","email":"williehickman@apextri.com","city":"Clay","state":"MS"} -{"account_number":931,"balance":8244,"firstname":"Ingrid","lastname":"Garcia","age":23,"gender":"F","address":"674 Indiana Place","employer":"Balooba","email":"ingridgarcia@balooba.com","city":"Interlochen","state":"AZ"} -{"account_number":936,"balance":22430,"firstname":"Beth","lastname":"Frye","age":36,"gender":"M","address":"462 Thatford Avenue","employer":"Puria","email":"bethfrye@puria.com","city":"Hiseville","state":"LA"} -{"account_number":943,"balance":24187,"firstname":"Wagner","lastname":"Griffin","age":23,"gender":"M","address":"489 Ellery Street","employer":"Gazak","email":"wagnergriffin@gazak.com","city":"Lorraine","state":"HI"} -{"account_number":948,"balance":37074,"firstname":"Sargent","lastname":"Powers","age":40,"gender":"M","address":"532 Fiske Place","employer":"Accuprint","email":"sargentpowers@accuprint.com","city":"Umapine","state":"AK"} -{"account_number":950,"balance":30916,"firstname":"Sherrie","lastname":"Patel","age":32,"gender":"F","address":"658 Langham Street","employer":"Futurize","email":"sherriepatel@futurize.com","city":"Garfield","state":"OR"} -{"account_number":955,"balance":41621,"firstname":"Klein","lastname":"Kemp","age":33,"gender":"M","address":"370 Vanderbilt Avenue","employer":"Synkgen","email":"kleinkemp@synkgen.com","city":"Bonanza","state":"FL"} -{"account_number":962,"balance":32096,"firstname":"Trujillo","lastname":"Wilcox","age":21,"gender":"F","address":"914 Duffield Street","employer":"Extragene","email":"trujillowilcox@extragene.com","city":"Golconda","state":"MA"} -{"account_number":967,"balance":19161,"firstname":"Carrie","lastname":"Huffman","age":36,"gender":"F","address":"240 Sands Street","employer":"Injoy","email":"carriehuffman@injoy.com","city":"Leroy","state":"CA"} -{"account_number":974,"balance":38082,"firstname":"Deborah","lastname":"Yang","age":26,"gender":"F","address":"463 Goodwin Place","employer":"Entogrok","email":"deborahyang@entogrok.com","city":"Herald","state":"KY"} -{"account_number":979,"balance":43130,"firstname":"Vaughn","lastname":"Pittman","age":29,"gender":"M","address":"446 Tompkins Place","employer":"Phormula","email":"vaughnpittman@phormula.com","city":"Fingerville","state":"WI"} -{"account_number":981,"balance":20278,"firstname":"Nolan","lastname":"Warner","age":29,"gender":"F","address":"753 Channel Avenue","employer":"Interodeo","email":"nolanwarner@interodeo.com","city":"Layhill","state":"MT"} -{"account_number":986,"balance":35086,"firstname":"Norris","lastname":"Hubbard","age":31,"gender":"M","address":"600 Celeste Court","employer":"Printspan","email":"norrishubbard@printspan.com","city":"Cassel","state":"MI"} -{"account_number":993,"balance":26487,"firstname":"Campos","lastname":"Olsen","age":37,"gender":"M","address":"873 Covert Street","employer":"Isbol","email":"camposolsen@isbol.com","city":"Glendale","state":"AK"} -{"account_number":998,"balance":16869,"firstname":"Letha","lastname":"Baker","age":40,"gender":"F","address":"206 Llama Court","employer":"Dognosis","email":"lethabaker@dognosis.com","city":"Dunlo","state":"WV"} -{"account_number":2,"balance":28838,"firstname":"Roberta","lastname":"Bender","age":22,"gender":"F","address":"560 Kingsway Place","employer":"Chillium","email":"robertabender@chillium.com","city":"Bennett","state":"LA"} -{"account_number":7,"balance":39121,"firstname":"Levy","lastname":"Richard","age":22,"gender":"M","address":"820 Logan Street","employer":"Teraprene","email":"levyrichard@teraprene.com","city":"Shrewsbury","state":"MO"} -{"account_number":14,"balance":20480,"firstname":"Erma","lastname":"Kane","age":39,"gender":"F","address":"661 Vista Place","employer":"Stockpost","email":"ermakane@stockpost.com","city":"Chamizal","state":"NY"} -{"account_number":19,"balance":27894,"firstname":"Schwartz","lastname":"Buchanan","age":28,"gender":"F","address":"449 Mersereau Court","employer":"Sybixtex","email":"schwartzbuchanan@sybixtex.com","city":"Greenwich","state":"KS"} -{"account_number":21,"balance":7004,"firstname":"Estella","lastname":"Paul","age":38,"gender":"M","address":"859 Portal Street","employer":"Zillatide","email":"estellapaul@zillatide.com","city":"Churchill","state":"WV"} -{"account_number":26,"balance":14127,"firstname":"Lorraine","lastname":"Mccullough","age":39,"gender":"F","address":"157 Dupont Street","employer":"Zosis","email":"lorrainemccullough@zosis.com","city":"Dennard","state":"NH"} -{"account_number":33,"balance":35439,"firstname":"Savannah","lastname":"Kirby","age":30,"gender":"F","address":"372 Malta Street","employer":"Musanpoly","email":"savannahkirby@musanpoly.com","city":"Muse","state":"AK"} -{"account_number":38,"balance":10511,"firstname":"Erna","lastname":"Fields","age":32,"gender":"M","address":"357 Maple Street","employer":"Eweville","email":"ernafields@eweville.com","city":"Twilight","state":"MS"} -{"account_number":40,"balance":33882,"firstname":"Pace","lastname":"Molina","age":40,"gender":"M","address":"263 Ovington Court","employer":"Cytrak","email":"pacemolina@cytrak.com","city":"Silkworth","state":"OR"} -{"account_number":45,"balance":44478,"firstname":"Geneva","lastname":"Morin","age":21,"gender":"F","address":"357 Herkimer Street","employer":"Ezent","email":"genevamorin@ezent.com","city":"Blanco","state":"AZ"} -{"account_number":52,"balance":46425,"firstname":"Kayla","lastname":"Bradshaw","age":31,"gender":"M","address":"449 Barlow Drive","employer":"Magnemo","email":"kaylabradshaw@magnemo.com","city":"Wawona","state":"AZ"} -{"account_number":57,"balance":8705,"firstname":"Powell","lastname":"Herring","age":21,"gender":"M","address":"263 Merit Court","employer":"Digiprint","email":"powellherring@digiprint.com","city":"Coral","state":"MT"} -{"account_number":64,"balance":44036,"firstname":"Miles","lastname":"Battle","age":35,"gender":"F","address":"988 Homecrest Avenue","employer":"Koffee","email":"milesbattle@koffee.com","city":"Motley","state":"ID"} -{"account_number":69,"balance":14253,"firstname":"Desiree","lastname":"Harrison","age":24,"gender":"M","address":"694 Garland Court","employer":"Barkarama","email":"desireeharrison@barkarama.com","city":"Hackneyville","state":"GA"} -{"account_number":71,"balance":38201,"firstname":"Sharpe","lastname":"Hoffman","age":39,"gender":"F","address":"450 Conklin Avenue","employer":"Centree","email":"sharpehoffman@centree.com","city":"Urbana","state":"WY"} -{"account_number":76,"balance":38345,"firstname":"Claudette","lastname":"Beard","age":24,"gender":"F","address":"748 Dorset Street","employer":"Repetwire","email":"claudettebeard@repetwire.com","city":"Caln","state":"TX"} -{"account_number":83,"balance":35928,"firstname":"Mayo","lastname":"Cleveland","age":28,"gender":"M","address":"720 Brooklyn Road","employer":"Indexia","email":"mayocleveland@indexia.com","city":"Roberts","state":"ND"} -{"account_number":88,"balance":26418,"firstname":"Adela","lastname":"Tyler","age":21,"gender":"F","address":"737 Clove Road","employer":"Surelogic","email":"adelatyler@surelogic.com","city":"Boling","state":"SD"} -{"account_number":90,"balance":25332,"firstname":"Herman","lastname":"Snyder","age":22,"gender":"F","address":"737 College Place","employer":"Lunchpod","email":"hermansnyder@lunchpod.com","city":"Flintville","state":"IA"} -{"account_number":95,"balance":1650,"firstname":"Dominguez","lastname":"Le","age":20,"gender":"M","address":"539 Grace Court","employer":"Portica","email":"dominguezle@portica.com","city":"Wollochet","state":"KS"} -{"account_number":103,"balance":11253,"firstname":"Calhoun","lastname":"Bruce","age":33,"gender":"F","address":"731 Clarkson Avenue","employer":"Automon","email":"calhounbruce@automon.com","city":"Marienthal","state":"IL"} -{"account_number":108,"balance":19015,"firstname":"Christensen","lastname":"Weaver","age":21,"gender":"M","address":"398 Dearborn Court","employer":"Quilk","email":"christensenweaver@quilk.com","city":"Belvoir","state":"TX"} -{"account_number":110,"balance":4850,"firstname":"Daphne","lastname":"Byrd","age":23,"gender":"F","address":"239 Conover Street","employer":"Freakin","email":"daphnebyrd@freakin.com","city":"Taft","state":"MN"} -{"account_number":115,"balance":18750,"firstname":"Nikki","lastname":"Doyle","age":31,"gender":"F","address":"537 Clara Street","employer":"Fossiel","email":"nikkidoyle@fossiel.com","city":"Caron","state":"MS"} -{"account_number":122,"balance":17128,"firstname":"Aurora","lastname":"Fry","age":31,"gender":"F","address":"227 Knapp Street","employer":"Makingway","email":"aurorafry@makingway.com","city":"Maybell","state":"NE"} -{"account_number":127,"balance":48734,"firstname":"Diann","lastname":"Mclaughlin","age":33,"gender":"F","address":"340 Clermont Avenue","employer":"Enomen","email":"diannmclaughlin@enomen.com","city":"Rutherford","state":"ND"} -{"account_number":134,"balance":33829,"firstname":"Madelyn","lastname":"Norris","age":30,"gender":"F","address":"176 Noel Avenue","employer":"Endicil","email":"madelynnorris@endicil.com","city":"Walker","state":"NE"} -{"account_number":139,"balance":18444,"firstname":"Rios","lastname":"Todd","age":35,"gender":"F","address":"281 Georgia Avenue","employer":"Uberlux","email":"riostodd@uberlux.com","city":"Hannasville","state":"PA"} -{"account_number":141,"balance":20790,"firstname":"Liliana","lastname":"Caldwell","age":29,"gender":"M","address":"414 Huron Street","employer":"Rubadub","email":"lilianacaldwell@rubadub.com","city":"Hiwasse","state":"OK"} -{"account_number":146,"balance":39078,"firstname":"Lang","lastname":"Kaufman","age":32,"gender":"F","address":"626 Beverley Road","employer":"Rodeomad","email":"langkaufman@rodeomad.com","city":"Mahtowa","state":"RI"} -{"account_number":153,"balance":32074,"firstname":"Bird","lastname":"Cochran","age":31,"gender":"F","address":"691 Bokee Court","employer":"Supremia","email":"birdcochran@supremia.com","city":"Barrelville","state":"NE"} -{"account_number":158,"balance":9380,"firstname":"Natalie","lastname":"Mcdowell","age":27,"gender":"M","address":"953 Roder Avenue","employer":"Myopium","email":"nataliemcdowell@myopium.com","city":"Savage","state":"ND"} -{"account_number":160,"balance":48974,"firstname":"Hull","lastname":"Cherry","age":23,"gender":"F","address":"275 Beaumont Street","employer":"Noralex","email":"hullcherry@noralex.com","city":"Whipholt","state":"WA"} -{"account_number":165,"balance":18956,"firstname":"Sims","lastname":"Mckay","age":40,"gender":"F","address":"205 Jackson Street","employer":"Comtour","email":"simsmckay@comtour.com","city":"Tilden","state":"DC"} -{"account_number":172,"balance":18356,"firstname":"Marie","lastname":"Whitehead","age":20,"gender":"M","address":"704 Monaco Place","employer":"Sultrax","email":"mariewhitehead@sultrax.com","city":"Dragoon","state":"IL"} -{"account_number":177,"balance":48972,"firstname":"Harris","lastname":"Gross","age":40,"gender":"F","address":"468 Suydam Street","employer":"Kidstock","email":"harrisgross@kidstock.com","city":"Yettem","state":"KY"} -{"account_number":184,"balance":9157,"firstname":"Cathy","lastname":"Morrison","age":27,"gender":"M","address":"882 Pine Street","employer":"Zytrek","email":"cathymorrison@zytrek.com","city":"Fedora","state":"FL"} -{"account_number":189,"balance":20167,"firstname":"Ada","lastname":"Cortez","age":38,"gender":"F","address":"700 Forest Place","employer":"Micronaut","email":"adacortez@micronaut.com","city":"Eagletown","state":"TX"} -{"account_number":191,"balance":26172,"firstname":"Barr","lastname":"Sharpe","age":28,"gender":"M","address":"428 Auburn Place","employer":"Ziggles","email":"barrsharpe@ziggles.com","city":"Springdale","state":"KS"} -{"account_number":196,"balance":29931,"firstname":"Caldwell","lastname":"Daniel","age":28,"gender":"F","address":"405 Oliver Street","employer":"Furnigeer","email":"caldwelldaniel@furnigeer.com","city":"Zortman","state":"NE"} -{"account_number":204,"balance":27714,"firstname":"Mavis","lastname":"Deleon","age":39,"gender":"F","address":"400 Waldane Court","employer":"Lotron","email":"mavisdeleon@lotron.com","city":"Stollings","state":"LA"} -{"account_number":209,"balance":31052,"firstname":"Myers","lastname":"Noel","age":30,"gender":"F","address":"691 Alton Place","employer":"Greeker","email":"myersnoel@greeker.com","city":"Hinsdale","state":"KY"} -{"account_number":211,"balance":21539,"firstname":"Graciela","lastname":"Vaughan","age":22,"gender":"M","address":"558 Montauk Court","employer":"Fishland","email":"gracielavaughan@fishland.com","city":"Madrid","state":"PA"} -{"account_number":216,"balance":11422,"firstname":"Price","lastname":"Haley","age":35,"gender":"M","address":"233 Portland Avenue","employer":"Zeam","email":"pricehaley@zeam.com","city":"Titanic","state":"UT"} -{"account_number":223,"balance":9528,"firstname":"Newton","lastname":"Fletcher","age":26,"gender":"F","address":"654 Dewitt Avenue","employer":"Assistia","email":"newtonfletcher@assistia.com","city":"Nipinnawasee","state":"AK"} -{"account_number":228,"balance":10543,"firstname":"Rosella","lastname":"Albert","age":20,"gender":"M","address":"185 Gotham Avenue","employer":"Isoplex","email":"rosellaalbert@isoplex.com","city":"Finzel","state":"NY"} -{"account_number":230,"balance":10829,"firstname":"Chris","lastname":"Raymond","age":28,"gender":"F","address":"464 Remsen Street","employer":"Cogentry","email":"chrisraymond@cogentry.com","city":"Bowmansville","state":"SD"} -{"account_number":235,"balance":17729,"firstname":"Mcpherson","lastname":"Mueller","age":31,"gender":"M","address":"541 Strong Place","employer":"Tingles","email":"mcphersonmueller@tingles.com","city":"Brantleyville","state":"AR"} -{"account_number":242,"balance":42318,"firstname":"Berger","lastname":"Roach","age":21,"gender":"M","address":"125 Wakeman Place","employer":"Ovium","email":"bergerroach@ovium.com","city":"Hessville","state":"WI"} -{"account_number":247,"balance":45123,"firstname":"Mccormick","lastname":"Moon","age":37,"gender":"M","address":"582 Brighton Avenue","employer":"Norsup","email":"mccormickmoon@norsup.com","city":"Forestburg","state":"DE"} -{"account_number":254,"balance":35104,"firstname":"Yang","lastname":"Dodson","age":21,"gender":"M","address":"531 Lott Street","employer":"Mondicil","email":"yangdodson@mondicil.com","city":"Enoree","state":"UT"} -{"account_number":259,"balance":41877,"firstname":"Eleanor","lastname":"Gonzalez","age":30,"gender":"M","address":"800 Sumpter Street","employer":"Futuris","email":"eleanorgonzalez@futuris.com","city":"Jenkinsville","state":"ID"} -{"account_number":261,"balance":39998,"firstname":"Millicent","lastname":"Pickett","age":34,"gender":"F","address":"722 Montieth Street","employer":"Gushkool","email":"millicentpickett@gushkool.com","city":"Norwood","state":"MS"} -{"account_number":266,"balance":2777,"firstname":"Monique","lastname":"Conner","age":35,"gender":"F","address":"489 Metrotech Courtr","employer":"Flotonic","email":"moniqueconner@flotonic.com","city":"Retsof","state":"MD"} -{"account_number":273,"balance":11181,"firstname":"Murphy","lastname":"Chandler","age":20,"gender":"F","address":"569 Bradford Street","employer":"Zilch","email":"murphychandler@zilch.com","city":"Vicksburg","state":"FL"} -{"account_number":278,"balance":22530,"firstname":"Tamra","lastname":"Navarro","age":27,"gender":"F","address":"175 Woodruff Avenue","employer":"Norsul","email":"tamranavarro@norsul.com","city":"Glasgow","state":"VT"} -{"account_number":280,"balance":3380,"firstname":"Vilma","lastname":"Shields","age":26,"gender":"F","address":"133 Berriman Street","employer":"Applidec","email":"vilmashields@applidec.com","city":"Adamstown","state":"ME"} -{"account_number":285,"balance":47369,"firstname":"Hilda","lastname":"Phillips","age":28,"gender":"F","address":"618 Nixon Court","employer":"Comcur","email":"hildaphillips@comcur.com","city":"Siglerville","state":"NC"} -{"account_number":292,"balance":26679,"firstname":"Morrow","lastname":"Greene","age":20,"gender":"F","address":"691 Nassau Street","employer":"Columella","email":"morrowgreene@columella.com","city":"Sanborn","state":"FL"} -{"account_number":297,"balance":20508,"firstname":"Tucker","lastname":"Patrick","age":35,"gender":"F","address":"978 Whitwell Place","employer":"Valreda","email":"tuckerpatrick@valreda.com","city":"Deseret","state":"CO"} -{"account_number":300,"balance":25654,"firstname":"Lane","lastname":"Tate","age":26,"gender":"F","address":"632 Kay Court","employer":"Genesynk","email":"lanetate@genesynk.com","city":"Lowell","state":"MO"} -{"account_number":305,"balance":11655,"firstname":"Augusta","lastname":"Winters","age":29,"gender":"F","address":"377 Paerdegat Avenue","employer":"Vendblend","email":"augustawinters@vendblend.com","city":"Gwynn","state":"MA"} -{"account_number":312,"balance":8511,"firstname":"Burgess","lastname":"Gentry","age":25,"gender":"F","address":"382 Bergen Court","employer":"Orbixtar","email":"burgessgentry@orbixtar.com","city":"Conestoga","state":"WI"} -{"account_number":317,"balance":31968,"firstname":"Ruiz","lastname":"Morris","age":31,"gender":"F","address":"972 Dean Street","employer":"Apex","email":"ruizmorris@apex.com","city":"Jacksonwald","state":"WV"} -{"account_number":324,"balance":44976,"firstname":"Gladys","lastname":"Erickson","age":22,"gender":"M","address":"250 Battery Avenue","employer":"Eternis","email":"gladyserickson@eternis.com","city":"Marne","state":"IA"} -{"account_number":329,"balance":31138,"firstname":"Nellie","lastname":"Mercer","age":25,"gender":"M","address":"967 Ebony Court","employer":"Scenty","email":"nelliemercer@scenty.com","city":"Jardine","state":"AK"} -{"account_number":331,"balance":46004,"firstname":"Gibson","lastname":"Potts","age":34,"gender":"F","address":"994 Dahill Road","employer":"Zensus","email":"gibsonpotts@zensus.com","city":"Frizzleburg","state":"CO"} -{"account_number":336,"balance":40891,"firstname":"Dudley","lastname":"Avery","age":25,"gender":"M","address":"405 Powers Street","employer":"Genmom","email":"dudleyavery@genmom.com","city":"Clarksburg","state":"CO"} -{"account_number":343,"balance":37684,"firstname":"Robbie","lastname":"Logan","age":29,"gender":"M","address":"488 Linden Boulevard","employer":"Hydrocom","email":"robbielogan@hydrocom.com","city":"Stockdale","state":"TN"} -{"account_number":348,"balance":1360,"firstname":"Karina","lastname":"Russell","age":37,"gender":"M","address":"797 Moffat Street","employer":"Limozen","email":"karinarussell@limozen.com","city":"Riegelwood","state":"RI"} -{"account_number":350,"balance":4267,"firstname":"Wyatt","lastname":"Wise","age":22,"gender":"F","address":"896 Bleecker Street","employer":"Rockyard","email":"wyattwise@rockyard.com","city":"Joes","state":"MS"} -{"account_number":355,"balance":40961,"firstname":"Gregory","lastname":"Delacruz","age":38,"gender":"M","address":"876 Cortelyou Road","employer":"Oulu","email":"gregorydelacruz@oulu.com","city":"Waterloo","state":"WV"} -{"account_number":362,"balance":14938,"firstname":"Jimmie","lastname":"Dejesus","age":26,"gender":"M","address":"351 Navy Walk","employer":"Ecolight","email":"jimmiedejesus@ecolight.com","city":"Berlin","state":"ME"} -{"account_number":367,"balance":40458,"firstname":"Elaine","lastname":"Workman","age":20,"gender":"M","address":"188 Ridge Boulevard","employer":"Colaire","email":"elaineworkman@colaire.com","city":"Herbster","state":"AK"} -{"account_number":374,"balance":19521,"firstname":"Blanchard","lastname":"Stein","age":30,"gender":"M","address":"313 Bartlett Street","employer":"Cujo","email":"blanchardstein@cujo.com","city":"Cascades","state":"OR"} -{"account_number":379,"balance":12962,"firstname":"Ruthie","lastname":"Lamb","age":21,"gender":"M","address":"796 Rockaway Avenue","employer":"Incubus","email":"ruthielamb@incubus.com","city":"Hickory","state":"TX"} -{"account_number":381,"balance":40978,"firstname":"Sophie","lastname":"Mays","age":31,"gender":"M","address":"261 Varanda Place","employer":"Uneeq","email":"sophiemays@uneeq.com","city":"Cressey","state":"AR"} -{"account_number":386,"balance":42588,"firstname":"Wallace","lastname":"Barr","age":39,"gender":"F","address":"246 Beverly Road","employer":"Concility","email":"wallacebarr@concility.com","city":"Durham","state":"IN"} -{"account_number":393,"balance":43936,"firstname":"William","lastname":"Kelly","age":24,"gender":"M","address":"178 Lawrence Avenue","employer":"Techtrix","email":"williamkelly@techtrix.com","city":"Orin","state":"PA"} -{"account_number":398,"balance":8543,"firstname":"Leticia","lastname":"Duran","age":35,"gender":"F","address":"305 Senator Street","employer":"Xleen","email":"leticiaduran@xleen.com","city":"Cavalero","state":"PA"} -{"account_number":401,"balance":29408,"firstname":"Contreras","lastname":"Randolph","age":38,"gender":"M","address":"104 Lewis Avenue","employer":"Inrt","email":"contrerasrandolph@inrt.com","city":"Chesapeake","state":"CT"} -{"account_number":406,"balance":28127,"firstname":"Mccarthy","lastname":"Dunlap","age":28,"gender":"F","address":"684 Seacoast Terrace","employer":"Canopoly","email":"mccarthydunlap@canopoly.com","city":"Elliott","state":"NC"} -{"account_number":413,"balance":15631,"firstname":"Pugh","lastname":"Hamilton","age":39,"gender":"F","address":"124 Euclid Avenue","employer":"Techade","email":"pughhamilton@techade.com","city":"Beaulieu","state":"CA"} -{"account_number":418,"balance":10207,"firstname":"Reed","lastname":"Goff","age":32,"gender":"M","address":"959 Everit Street","employer":"Zillan","email":"reedgoff@zillan.com","city":"Hiko","state":"WV"} -{"account_number":420,"balance":44699,"firstname":"Brandie","lastname":"Hayden","age":22,"gender":"M","address":"291 Ash Street","employer":"Digifad","email":"brandiehayden@digifad.com","city":"Spelter","state":"NM"} -{"account_number":425,"balance":41308,"firstname":"Queen","lastname":"Leach","age":30,"gender":"M","address":"105 Fair Street","employer":"Magneato","email":"queenleach@magneato.com","city":"Barronett","state":"NH"} -{"account_number":432,"balance":28969,"firstname":"Preston","lastname":"Ferguson","age":40,"gender":"F","address":"239 Greenwood Avenue","employer":"Bitendrex","email":"prestonferguson@bitendrex.com","city":"Idledale","state":"ND"} -{"account_number":437,"balance":41225,"firstname":"Rosales","lastname":"Marquez","age":29,"gender":"M","address":"873 Ryerson Street","employer":"Ronelon","email":"rosalesmarquez@ronelon.com","city":"Allendale","state":"CA"} -{"account_number":444,"balance":44219,"firstname":"Dolly","lastname":"Finch","age":24,"gender":"F","address":"974 Interborough Parkway","employer":"Zytrac","email":"dollyfinch@zytrac.com","city":"Vowinckel","state":"WY"} -{"account_number":449,"balance":41950,"firstname":"Barnett","lastname":"Cantrell","age":39,"gender":"F","address":"945 Bedell Lane","employer":"Zentility","email":"barnettcantrell@zentility.com","city":"Swartzville","state":"ND"} -{"account_number":451,"balance":31950,"firstname":"Mason","lastname":"Mcleod","age":31,"gender":"F","address":"438 Havemeyer Street","employer":"Omatom","email":"masonmcleod@omatom.com","city":"Ryderwood","state":"NE"} -{"account_number":456,"balance":21419,"firstname":"Solis","lastname":"Kline","age":33,"gender":"M","address":"818 Ashford Street","employer":"Vetron","email":"soliskline@vetron.com","city":"Ruffin","state":"NY"} -{"account_number":463,"balance":36672,"firstname":"Heidi","lastname":"Acosta","age":20,"gender":"F","address":"692 Kenmore Terrace","employer":"Elpro","email":"heidiacosta@elpro.com","city":"Ezel","state":"SD"} -{"account_number":468,"balance":18400,"firstname":"Foreman","lastname":"Fowler","age":40,"gender":"M","address":"443 Jackson Court","employer":"Zillactic","email":"foremanfowler@zillactic.com","city":"Wakarusa","state":"WA"} -{"account_number":470,"balance":20455,"firstname":"Schneider","lastname":"Hull","age":35,"gender":"M","address":"724 Apollo Street","employer":"Exospeed","email":"schneiderhull@exospeed.com","city":"Watchtower","state":"ID"} -{"account_number":475,"balance":24427,"firstname":"Morales","lastname":"Jacobs","age":22,"gender":"F","address":"225 Desmond Court","employer":"Oronoko","email":"moralesjacobs@oronoko.com","city":"Clayville","state":"CT"} -{"account_number":482,"balance":14834,"firstname":"Janie","lastname":"Bass","age":39,"gender":"M","address":"781 Grattan Street","employer":"Manglo","email":"janiebass@manglo.com","city":"Kenwood","state":"IA"} -{"account_number":487,"balance":30718,"firstname":"Sawyer","lastname":"Vincent","age":26,"gender":"F","address":"238 Lancaster Avenue","employer":"Brainquil","email":"sawyervincent@brainquil.com","city":"Galesville","state":"MS"} -{"account_number":494,"balance":3592,"firstname":"Holden","lastname":"Bowen","age":30,"gender":"M","address":"374 Elmwood Avenue","employer":"Endipine","email":"holdenbowen@endipine.com","city":"Rosine","state":"ID"} -{"account_number":499,"balance":26060,"firstname":"Lara","lastname":"Perkins","age":26,"gender":"M","address":"703 Monroe Street","employer":"Paprikut","email":"laraperkins@paprikut.com","city":"Barstow","state":"NY"} -{"account_number":502,"balance":31898,"firstname":"Woodard","lastname":"Bailey","age":31,"gender":"F","address":"585 Albee Square","employer":"Imperium","email":"woodardbailey@imperium.com","city":"Matheny","state":"MT"} -{"account_number":507,"balance":27675,"firstname":"Blankenship","lastname":"Ramirez","age":31,"gender":"M","address":"630 Graham Avenue","employer":"Bytrex","email":"blankenshipramirez@bytrex.com","city":"Bancroft","state":"CT"} -{"account_number":514,"balance":30125,"firstname":"Solomon","lastname":"Bush","age":34,"gender":"M","address":"409 Harkness Avenue","employer":"Snacktion","email":"solomonbush@snacktion.com","city":"Grayhawk","state":"TX"} -{"account_number":519,"balance":3282,"firstname":"Lorna","lastname":"Franco","age":31,"gender":"F","address":"722 Schenck Court","employer":"Zentia","email":"lornafranco@zentia.com","city":"National","state":"FL"} -{"account_number":521,"balance":16348,"firstname":"Josefa","lastname":"Buckley","age":34,"gender":"F","address":"848 Taylor Street","employer":"Mazuda","email":"josefabuckley@mazuda.com","city":"Saranap","state":"NM"} -{"account_number":526,"balance":35375,"firstname":"Sweeney","lastname":"Fulton","age":33,"gender":"F","address":"550 Martense Street","employer":"Cormoran","email":"sweeneyfulton@cormoran.com","city":"Chalfant","state":"IA"} -{"account_number":533,"balance":13761,"firstname":"Margarita","lastname":"Diaz","age":23,"gender":"M","address":"295 Tapscott Street","employer":"Zilodyne","email":"margaritadiaz@zilodyne.com","city":"Hondah","state":"ID"} -{"account_number":538,"balance":16416,"firstname":"Koch","lastname":"Barker","age":21,"gender":"M","address":"919 Gerry Street","employer":"Xplor","email":"kochbarker@xplor.com","city":"Dixie","state":"WY"} -{"account_number":540,"balance":40235,"firstname":"Tammy","lastname":"Wiggins","age":32,"gender":"F","address":"186 Schenectady Avenue","employer":"Speedbolt","email":"tammywiggins@speedbolt.com","city":"Salvo","state":"LA"} -{"account_number":545,"balance":27011,"firstname":"Lena","lastname":"Lucas","age":20,"gender":"M","address":"110 Lamont Court","employer":"Kindaloo","email":"lenalucas@kindaloo.com","city":"Harleigh","state":"KY"} -{"account_number":552,"balance":14727,"firstname":"Kate","lastname":"Estes","age":39,"gender":"M","address":"785 Willmohr Street","employer":"Rodeocean","email":"kateestes@rodeocean.com","city":"Elfrida","state":"HI"} -{"account_number":557,"balance":3119,"firstname":"Landry","lastname":"Buck","age":20,"gender":"M","address":"558 Schweikerts Walk","employer":"Protodyne","email":"landrybuck@protodyne.com","city":"Edneyville","state":"AL"} -{"account_number":564,"balance":43631,"firstname":"Owens","lastname":"Bowers","age":22,"gender":"M","address":"842 Congress Street","employer":"Nspire","email":"owensbowers@nspire.com","city":"Machias","state":"VA"} -{"account_number":569,"balance":40019,"firstname":"Sherri","lastname":"Rowe","age":39,"gender":"F","address":"591 Arlington Place","employer":"Netility","email":"sherrirowe@netility.com","city":"Bridgetown","state":"SC"} -{"account_number":571,"balance":3014,"firstname":"Ayers","lastname":"Duffy","age":28,"gender":"F","address":"721 Wortman Avenue","employer":"Aquasseur","email":"ayersduffy@aquasseur.com","city":"Tilleda","state":"MS"} -{"account_number":576,"balance":29682,"firstname":"Helena","lastname":"Robertson","age":33,"gender":"F","address":"774 Devon Avenue","employer":"Vicon","email":"helenarobertson@vicon.com","city":"Dyckesville","state":"NV"} -{"account_number":583,"balance":26558,"firstname":"Castro","lastname":"West","age":34,"gender":"F","address":"814 Williams Avenue","employer":"Cipromox","email":"castrowest@cipromox.com","city":"Nescatunga","state":"IL"} -{"account_number":588,"balance":43531,"firstname":"Martina","lastname":"Collins","age":31,"gender":"M","address":"301 Anna Court","employer":"Geekwagon","email":"martinacollins@geekwagon.com","city":"Oneida","state":"VA"} -{"account_number":590,"balance":4652,"firstname":"Ladonna","lastname":"Tucker","age":31,"gender":"F","address":"162 Kane Place","employer":"Infotrips","email":"ladonnatucker@infotrips.com","city":"Utting","state":"IA"} -{"account_number":595,"balance":12478,"firstname":"Mccall","lastname":"Britt","age":36,"gender":"F","address":"823 Hill Street","employer":"Cablam","email":"mccallbritt@cablam.com","city":"Vernon","state":"CA"} -{"account_number":603,"balance":28145,"firstname":"Janette","lastname":"Guzman","age":31,"gender":"F","address":"976 Kingston Avenue","employer":"Splinx","email":"janetteguzman@splinx.com","city":"Boomer","state":"NC"} -{"account_number":608,"balance":47091,"firstname":"Carey","lastname":"Whitley","age":32,"gender":"F","address":"976 Lawrence Street","employer":"Poshome","email":"careywhitley@poshome.com","city":"Weogufka","state":"NE"} -{"account_number":610,"balance":40571,"firstname":"Foster","lastname":"Weber","age":24,"gender":"F","address":"323 Rochester Avenue","employer":"Firewax","email":"fosterweber@firewax.com","city":"Winston","state":"NY"} -{"account_number":615,"balance":28726,"firstname":"Delgado","lastname":"Curry","age":28,"gender":"F","address":"706 Butler Street","employer":"Zoxy","email":"delgadocurry@zoxy.com","city":"Gracey","state":"SD"} -{"account_number":622,"balance":9661,"firstname":"Paulette","lastname":"Hartman","age":38,"gender":"M","address":"375 Emerald Street","employer":"Locazone","email":"paulettehartman@locazone.com","city":"Canterwood","state":"OH"} -{"account_number":627,"balance":47546,"firstname":"Crawford","lastname":"Sears","age":37,"gender":"F","address":"686 Eastern Parkway","employer":"Updat","email":"crawfordsears@updat.com","city":"Bison","state":"VT"} -{"account_number":634,"balance":29805,"firstname":"Deloris","lastname":"Levy","age":38,"gender":"M","address":"838 Foster Avenue","employer":"Homelux","email":"delorislevy@homelux.com","city":"Kempton","state":"PA"} -{"account_number":639,"balance":28875,"firstname":"Caitlin","lastname":"Clements","age":32,"gender":"F","address":"627 Aster Court","employer":"Bunga","email":"caitlinclements@bunga.com","city":"Cetronia","state":"SC"} -{"account_number":641,"balance":18345,"firstname":"Sheppard","lastname":"Everett","age":39,"gender":"F","address":"791 Norwood Avenue","employer":"Roboid","email":"sheppardeverett@roboid.com","city":"Selma","state":"AK"} -{"account_number":646,"balance":15559,"firstname":"Lavonne","lastname":"Reyes","age":31,"gender":"F","address":"983 Newport Street","employer":"Parcoe","email":"lavonnereyes@parcoe.com","city":"Monument","state":"LA"} -{"account_number":653,"balance":7606,"firstname":"Marcia","lastname":"Bennett","age":33,"gender":"F","address":"455 Bragg Street","employer":"Opticall","email":"marciabennett@opticall.com","city":"Magnolia","state":"NC"} -{"account_number":658,"balance":10210,"firstname":"Bass","lastname":"Mcconnell","age":32,"gender":"F","address":"274 Ocean Avenue","employer":"Combot","email":"bassmcconnell@combot.com","city":"Beyerville","state":"OH"} -{"account_number":660,"balance":46427,"firstname":"Moon","lastname":"Wood","age":33,"gender":"F","address":"916 Amersfort Place","employer":"Olucore","email":"moonwood@olucore.com","city":"Como","state":"VA"} -{"account_number":665,"balance":15215,"firstname":"Britney","lastname":"Young","age":36,"gender":"M","address":"766 Sackman Street","employer":"Geoforma","email":"britneyyoung@geoforma.com","city":"Tuttle","state":"WI"} -{"account_number":672,"balance":12621,"firstname":"Camille","lastname":"Munoz","age":36,"gender":"F","address":"959 Lewis Place","employer":"Vantage","email":"camillemunoz@vantage.com","city":"Whitmer","state":"IN"} -{"account_number":677,"balance":8491,"firstname":"Snider","lastname":"Benton","age":26,"gender":"M","address":"827 Evans Street","employer":"Medicroix","email":"sniderbenton@medicroix.com","city":"Kaka","state":"UT"} -{"account_number":684,"balance":46091,"firstname":"Warren","lastname":"Snow","age":25,"gender":"M","address":"756 Oakland Place","employer":"Bizmatic","email":"warrensnow@bizmatic.com","city":"Hatteras","state":"NE"} -{"account_number":689,"balance":14985,"firstname":"Ines","lastname":"Chaney","age":28,"gender":"M","address":"137 Dikeman Street","employer":"Zidant","email":"ineschaney@zidant.com","city":"Nettie","state":"DC"} -{"account_number":691,"balance":10792,"firstname":"Mclean","lastname":"Colon","age":22,"gender":"M","address":"876 Classon Avenue","employer":"Elentrix","email":"mcleancolon@elentrix.com","city":"Unionville","state":"OK"} -{"account_number":696,"balance":17568,"firstname":"Crane","lastname":"Matthews","age":32,"gender":"F","address":"721 Gerritsen Avenue","employer":"Intradisk","email":"cranematthews@intradisk.com","city":"Brewster","state":"WV"} -{"account_number":704,"balance":45347,"firstname":"Peters","lastname":"Kent","age":22,"gender":"F","address":"871 Independence Avenue","employer":"Extragen","email":"peterskent@extragen.com","city":"Morriston","state":"CA"} -{"account_number":709,"balance":11015,"firstname":"Abbott","lastname":"Odom","age":29,"gender":"M","address":"893 Union Street","employer":"Jimbies","email":"abbottodom@jimbies.com","city":"Leeper","state":"NJ"} -{"account_number":711,"balance":26939,"firstname":"Villarreal","lastname":"Horton","age":35,"gender":"F","address":"861 Creamer Street","employer":"Lexicondo","email":"villarrealhorton@lexicondo.com","city":"Lydia","state":"MS"} -{"account_number":716,"balance":19789,"firstname":"Paul","lastname":"Mason","age":34,"gender":"F","address":"618 Nichols Avenue","employer":"Slax","email":"paulmason@slax.com","city":"Snowville","state":"OK"} -{"account_number":723,"balance":16421,"firstname":"Nixon","lastname":"Moran","age":27,"gender":"M","address":"569 Campus Place","employer":"Cuizine","email":"nixonmoran@cuizine.com","city":"Buxton","state":"DC"} -{"account_number":728,"balance":44818,"firstname":"Conley","lastname":"Preston","age":28,"gender":"M","address":"450 Coventry Road","employer":"Obones","email":"conleypreston@obones.com","city":"Alden","state":"CO"} -{"account_number":730,"balance":41299,"firstname":"Moore","lastname":"Lee","age":30,"gender":"M","address":"797 Turner Place","employer":"Orbean","email":"moorelee@orbean.com","city":"Highland","state":"DE"} -{"account_number":735,"balance":3984,"firstname":"Loraine","lastname":"Willis","age":32,"gender":"F","address":"928 Grove Street","employer":"Gadtron","email":"lorainewillis@gadtron.com","city":"Lowgap","state":"NY"} -{"account_number":742,"balance":24765,"firstname":"Merle","lastname":"Wooten","age":26,"gender":"M","address":"317 Pooles Lane","employer":"Tropolis","email":"merlewooten@tropolis.com","city":"Bentley","state":"ND"} -{"account_number":747,"balance":16617,"firstname":"Diaz","lastname":"Austin","age":38,"gender":"M","address":"676 Harway Avenue","employer":"Irack","email":"diazaustin@irack.com","city":"Cliff","state":"HI"} -{"account_number":754,"balance":10779,"firstname":"Jones","lastname":"Vega","age":25,"gender":"F","address":"795 India Street","employer":"Gluid","email":"jonesvega@gluid.com","city":"Tyhee","state":"FL"} -{"account_number":759,"balance":38007,"firstname":"Rose","lastname":"Carlson","age":27,"gender":"M","address":"987 Navy Street","employer":"Aquasure","email":"rosecarlson@aquasure.com","city":"Carlton","state":"CT"} -{"account_number":761,"balance":7663,"firstname":"Rae","lastname":"Juarez","age":34,"gender":"F","address":"560 Gilmore Court","employer":"Entropix","email":"raejuarez@entropix.com","city":"Northchase","state":"ID"} -{"account_number":766,"balance":21957,"firstname":"Thomas","lastname":"Gillespie","age":38,"gender":"M","address":"993 Williams Place","employer":"Octocore","email":"thomasgillespie@octocore.com","city":"Defiance","state":"MS"} -{"account_number":773,"balance":31126,"firstname":"Liza","lastname":"Coffey","age":36,"gender":"F","address":"540 Bulwer Place","employer":"Assurity","email":"lizacoffey@assurity.com","city":"Gilgo","state":"WV"} -{"account_number":778,"balance":46007,"firstname":"Underwood","lastname":"Wheeler","age":28,"gender":"M","address":"477 Provost Street","employer":"Decratex","email":"underwoodwheeler@decratex.com","city":"Sardis","state":"ID"} -{"account_number":780,"balance":4682,"firstname":"Maryanne","lastname":"Hendricks","age":26,"gender":"F","address":"709 Wolcott Street","employer":"Sarasonic","email":"maryannehendricks@sarasonic.com","city":"Santel","state":"NH"} -{"account_number":785,"balance":25078,"firstname":"Fields","lastname":"Lester","age":29,"gender":"M","address":"808 Chestnut Avenue","employer":"Visualix","email":"fieldslester@visualix.com","city":"Rowe","state":"PA"} -{"account_number":792,"balance":13109,"firstname":"Becky","lastname":"Jimenez","age":40,"gender":"F","address":"539 Front Street","employer":"Isologia","email":"beckyjimenez@isologia.com","city":"Summertown","state":"MI"} -{"account_number":797,"balance":6854,"firstname":"Lindsay","lastname":"Mills","age":26,"gender":"F","address":"919 Quay Street","employer":"Zoinage","email":"lindsaymills@zoinage.com","city":"Elliston","state":"VA"} -{"account_number":800,"balance":26217,"firstname":"Candy","lastname":"Oconnor","age":28,"gender":"M","address":"200 Newel Street","employer":"Radiantix","email":"candyoconnor@radiantix.com","city":"Sandston","state":"OH"} -{"account_number":805,"balance":18426,"firstname":"Jackson","lastname":"Sampson","age":27,"gender":"F","address":"722 Kenmore Court","employer":"Daido","email":"jacksonsampson@daido.com","city":"Bellamy","state":"ME"} -{"account_number":812,"balance":42593,"firstname":"Graves","lastname":"Newman","age":32,"gender":"F","address":"916 Joralemon Street","employer":"Ecrater","email":"gravesnewman@ecrater.com","city":"Crown","state":"PA"} -{"account_number":817,"balance":36582,"firstname":"Padilla","lastname":"Bauer","age":36,"gender":"F","address":"310 Cadman Plaza","employer":"Exoblue","email":"padillabauer@exoblue.com","city":"Ahwahnee","state":"MN"} -{"account_number":824,"balance":6053,"firstname":"Dyer","lastname":"Henson","age":33,"gender":"M","address":"650 Seaview Avenue","employer":"Nitracyr","email":"dyerhenson@nitracyr.com","city":"Gibsonia","state":"KS"} -{"account_number":829,"balance":20263,"firstname":"Althea","lastname":"Bell","age":37,"gender":"M","address":"319 Cook Street","employer":"Hyplex","email":"altheabell@hyplex.com","city":"Wadsworth","state":"DC"} -{"account_number":831,"balance":25375,"firstname":"Wendy","lastname":"Savage","age":37,"gender":"M","address":"421 Veranda Place","employer":"Neurocell","email":"wendysavage@neurocell.com","city":"Fresno","state":"MS"} -{"account_number":836,"balance":20797,"firstname":"Lloyd","lastname":"Lindsay","age":25,"gender":"F","address":"953 Dinsmore Place","employer":"Suretech","email":"lloydlindsay@suretech.com","city":"Conway","state":"VA"} -{"account_number":843,"balance":15555,"firstname":"Patricia","lastname":"Barton","age":34,"gender":"F","address":"406 Seabring Street","employer":"Providco","email":"patriciabarton@providco.com","city":"Avoca","state":"RI"} -{"account_number":848,"balance":15443,"firstname":"Carmella","lastname":"Cash","age":38,"gender":"M","address":"988 Exeter Street","employer":"Bristo","email":"carmellacash@bristo.com","city":"Northridge","state":"ID"} -{"account_number":850,"balance":6531,"firstname":"Carlene","lastname":"Gaines","age":37,"gender":"F","address":"753 Monroe Place","employer":"Naxdis","email":"carlenegaines@naxdis.com","city":"Genoa","state":"OR"} -{"account_number":855,"balance":40170,"firstname":"Mia","lastname":"Stevens","age":31,"gender":"F","address":"326 Driggs Avenue","employer":"Aeora","email":"miastevens@aeora.com","city":"Delwood","state":"IL"} -{"account_number":862,"balance":38792,"firstname":"Clayton","lastname":"Golden","age":38,"gender":"F","address":"620 Regent Place","employer":"Accusage","email":"claytongolden@accusage.com","city":"Ona","state":"NC"} -{"account_number":867,"balance":45453,"firstname":"Blanca","lastname":"Ellison","age":23,"gender":"F","address":"593 McKibben Street","employer":"Koogle","email":"blancaellison@koogle.com","city":"Frystown","state":"WY"} -{"account_number":874,"balance":23079,"firstname":"Lynette","lastname":"Higgins","age":22,"gender":"M","address":"377 McKinley Avenue","employer":"Menbrain","email":"lynettehiggins@menbrain.com","city":"Manitou","state":"TX"} -{"account_number":879,"balance":48332,"firstname":"Sabrina","lastname":"Lancaster","age":31,"gender":"F","address":"382 Oak Street","employer":"Webiotic","email":"sabrinalancaster@webiotic.com","city":"Lindisfarne","state":"AZ"} -{"account_number":881,"balance":26684,"firstname":"Barnes","lastname":"Ware","age":38,"gender":"F","address":"666 Hooper Street","employer":"Norali","email":"barnesware@norali.com","city":"Cazadero","state":"GA"} -{"account_number":886,"balance":14867,"firstname":"Willa","lastname":"Leblanc","age":38,"gender":"F","address":"773 Bergen Street","employer":"Nurali","email":"willaleblanc@nurali.com","city":"Hilltop","state":"NC"} -{"account_number":893,"balance":42584,"firstname":"Moses","lastname":"Campos","age":38,"gender":"F","address":"991 Bevy Court","employer":"Trollery","email":"mosescampos@trollery.com","city":"Freetown","state":"AK"} -{"account_number":898,"balance":12019,"firstname":"Lori","lastname":"Stevenson","age":29,"gender":"M","address":"910 Coles Street","employer":"Honotron","email":"loristevenson@honotron.com","city":"Shindler","state":"VT"} -{"account_number":901,"balance":35038,"firstname":"Irma","lastname":"Dotson","age":23,"gender":"F","address":"245 Mayfair Drive","employer":"Bleeko","email":"irmadotson@bleeko.com","city":"Lodoga","state":"UT"} -{"account_number":906,"balance":24073,"firstname":"Vicki","lastname":"Suarez","age":36,"gender":"M","address":"829 Roosevelt Place","employer":"Utara","email":"vickisuarez@utara.com","city":"Albrightsville","state":"AR"} -{"account_number":913,"balance":47657,"firstname":"Margery","lastname":"Monroe","age":25,"gender":"M","address":"941 Fanchon Place","employer":"Exerta","email":"margerymonroe@exerta.com","city":"Bannock","state":"MD"} -{"account_number":918,"balance":36776,"firstname":"Dianna","lastname":"Hernandez","age":25,"gender":"M","address":"499 Moultrie Street","employer":"Isologica","email":"diannahernandez@isologica.com","city":"Falconaire","state":"ID"} -{"account_number":920,"balance":41513,"firstname":"Jerri","lastname":"Mitchell","age":26,"gender":"M","address":"831 Kent Street","employer":"Tasmania","email":"jerrimitchell@tasmania.com","city":"Cotopaxi","state":"IA"} -{"account_number":925,"balance":18295,"firstname":"Rosario","lastname":"Jackson","age":24,"gender":"M","address":"178 Leonora Court","employer":"Progenex","email":"rosariojackson@progenex.com","city":"Rivereno","state":"DE"} -{"account_number":932,"balance":3111,"firstname":"Summer","lastname":"Porter","age":33,"gender":"F","address":"949 Grand Avenue","employer":"Multiflex","email":"summerporter@multiflex.com","city":"Spokane","state":"OK"} -{"account_number":937,"balance":43491,"firstname":"Selma","lastname":"Anderson","age":24,"gender":"M","address":"205 Reed Street","employer":"Dadabase","email":"selmaanderson@dadabase.com","city":"Malo","state":"AL"} -{"account_number":944,"balance":46478,"firstname":"Donaldson","lastname":"Woodard","age":38,"gender":"F","address":"498 Laurel Avenue","employer":"Zogak","email":"donaldsonwoodard@zogak.com","city":"Hasty","state":"ID"} -{"account_number":949,"balance":48703,"firstname":"Latasha","lastname":"Mullins","age":29,"gender":"F","address":"272 Lefferts Place","employer":"Zenolux","email":"latashamullins@zenolux.com","city":"Kieler","state":"MN"} -{"account_number":951,"balance":36337,"firstname":"Tran","lastname":"Burris","age":25,"gender":"F","address":"561 Rutland Road","employer":"Geoform","email":"tranburris@geoform.com","city":"Longbranch","state":"IL"} -{"account_number":956,"balance":19477,"firstname":"Randall","lastname":"Lynch","age":22,"gender":"F","address":"490 Madison Place","employer":"Cosmetex","email":"randalllynch@cosmetex.com","city":"Wells","state":"SD"} -{"account_number":963,"balance":30461,"firstname":"Griffin","lastname":"Sheppard","age":20,"gender":"M","address":"682 Linden Street","employer":"Zanymax","email":"griffinsheppard@zanymax.com","city":"Fannett","state":"NM"} -{"account_number":968,"balance":32371,"firstname":"Luella","lastname":"Burch","age":39,"gender":"M","address":"684 Arkansas Drive","employer":"Krag","email":"luellaburch@krag.com","city":"Brambleton","state":"SD"} -{"account_number":970,"balance":19648,"firstname":"Forbes","lastname":"Wallace","age":28,"gender":"M","address":"990 Mill Road","employer":"Pheast","email":"forbeswallace@pheast.com","city":"Lopezo","state":"AK"} -{"account_number":975,"balance":5239,"firstname":"Delores","lastname":"Booker","age":27,"gender":"F","address":"328 Conselyea Street","employer":"Centice","email":"deloresbooker@centice.com","city":"Williams","state":"HI"} -{"account_number":982,"balance":16511,"firstname":"Buck","lastname":"Robinson","age":24,"gender":"M","address":"301 Melrose Street","employer":"Calcu","email":"buckrobinson@calcu.com","city":"Welch","state":"PA"} -{"account_number":987,"balance":4072,"firstname":"Brock","lastname":"Sandoval","age":20,"gender":"F","address":"977 Gem Street","employer":"Fiberox","email":"brocksandoval@fiberox.com","city":"Celeryville","state":"NY"} -{"account_number":994,"balance":33298,"firstname":"Madge","lastname":"Holcomb","age":31,"gender":"M","address":"612 Hawthorne Street","employer":"Escenta","email":"madgeholcomb@escenta.com","city":"Alafaya","state":"OR"} -{"account_number":999,"balance":6087,"firstname":"Dorothy","lastname":"Barron","age":22,"gender":"F","address":"499 Laurel Avenue","employer":"Xurban","email":"dorothybarron@xurban.com","city":"Belvoir","state":"CA"} -{"account_number":4,"balance":27658,"firstname":"Rodriquez","lastname":"Flores","age":31,"gender":"F","address":"986 Wyckoff Avenue","employer":"Tourmania","email":"rodriquezflores@tourmania.com","city":"Eastvale","state":"HI"} -{"account_number":9,"balance":24776,"firstname":"Opal","lastname":"Meadows","age":39,"gender":"M","address":"963 Neptune Avenue","employer":"Cedward","email":"opalmeadows@cedward.com","city":"Olney","state":"OH"} -{"account_number":11,"balance":20203,"firstname":"Jenkins","lastname":"Haney","age":20,"gender":"M","address":"740 Ferry Place","employer":"Qimonk","email":"jenkinshaney@qimonk.com","city":"Steinhatchee","state":"GA"} -{"account_number":16,"balance":35883,"firstname":"Adrian","lastname":"Pitts","age":34,"gender":"F","address":"963 Fay Court","employer":"Combogene","email":"adrianpitts@combogene.com","city":"Remington","state":"SD"} -{"account_number":23,"balance":42374,"firstname":"Kirsten","lastname":"Fox","age":20,"gender":"M","address":"330 Dumont Avenue","employer":"Codax","email":"kirstenfox@codax.com","city":"Walton","state":"AK"} -{"account_number":28,"balance":42112,"firstname":"Vega","lastname":"Flynn","age":20,"gender":"M","address":"647 Hyman Court","employer":"Accupharm","email":"vegaflynn@accupharm.com","city":"Masthope","state":"OH"} -{"account_number":30,"balance":19087,"firstname":"Lamb","lastname":"Townsend","age":26,"gender":"M","address":"169 Lyme Avenue","employer":"Geeknet","email":"lambtownsend@geeknet.com","city":"Epworth","state":"AL"} -{"account_number":35,"balance":42039,"firstname":"Darla","lastname":"Bridges","age":27,"gender":"F","address":"315 Central Avenue","employer":"Xeronk","email":"darlabridges@xeronk.com","city":"Woodlake","state":"RI"} -{"account_number":42,"balance":21137,"firstname":"Harding","lastname":"Hobbs","age":26,"gender":"F","address":"474 Ridgewood Place","employer":"Xth","email":"hardinghobbs@xth.com","city":"Heil","state":"ND"} -{"account_number":47,"balance":33044,"firstname":"Georgia","lastname":"Wilkerson","age":23,"gender":"M","address":"369 Herbert Street","employer":"Endipin","email":"georgiawilkerson@endipin.com","city":"Dellview","state":"WI"} -{"account_number":54,"balance":23406,"firstname":"Angel","lastname":"Mann","age":22,"gender":"F","address":"229 Ferris Street","employer":"Amtas","email":"angelmann@amtas.com","city":"Calverton","state":"WA"} -{"account_number":59,"balance":37728,"firstname":"Malone","lastname":"Justice","age":37,"gender":"F","address":"721 Russell Street","employer":"Emoltra","email":"malonejustice@emoltra.com","city":"Trucksville","state":"HI"} -{"account_number":61,"balance":6856,"firstname":"Shawn","lastname":"Baird","age":20,"gender":"M","address":"605 Monument Walk","employer":"Moltonic","email":"shawnbaird@moltonic.com","city":"Darlington","state":"MN"} -{"account_number":66,"balance":25939,"firstname":"Franks","lastname":"Salinas","age":28,"gender":"M","address":"437 Hamilton Walk","employer":"Cowtown","email":"frankssalinas@cowtown.com","city":"Chase","state":"VT"} -{"account_number":73,"balance":33457,"firstname":"Irene","lastname":"Stephenson","age":32,"gender":"M","address":"684 Miller Avenue","employer":"Hawkster","email":"irenestephenson@hawkster.com","city":"Levant","state":"AR"} -{"account_number":78,"balance":48656,"firstname":"Elvira","lastname":"Patterson","age":23,"gender":"F","address":"834 Amber Street","employer":"Assistix","email":"elvirapatterson@assistix.com","city":"Dunbar","state":"TN"} -{"account_number":80,"balance":13445,"firstname":"Lacey","lastname":"Blanchard","age":30,"gender":"F","address":"823 Himrod Street","employer":"Comdom","email":"laceyblanchard@comdom.com","city":"Matthews","state":"MO"} -{"account_number":85,"balance":48735,"firstname":"Wilcox","lastname":"Sellers","age":20,"gender":"M","address":"212 Irving Avenue","employer":"Confrenzy","email":"wilcoxsellers@confrenzy.com","city":"Kipp","state":"MT"} -{"account_number":92,"balance":26753,"firstname":"Gay","lastname":"Brewer","age":34,"gender":"M","address":"369 Ditmars Street","employer":"Savvy","email":"gaybrewer@savvy.com","city":"Moquino","state":"HI"} -{"account_number":97,"balance":49671,"firstname":"Karen","lastname":"Trujillo","age":40,"gender":"F","address":"512 Cumberland Walk","employer":"Tsunamia","email":"karentrujillo@tsunamia.com","city":"Fredericktown","state":"MO"} -{"account_number":100,"balance":29869,"firstname":"Madden","lastname":"Woods","age":32,"gender":"F","address":"696 Ryder Avenue","employer":"Slumberia","email":"maddenwoods@slumberia.com","city":"Deercroft","state":"ME"} -{"account_number":105,"balance":29654,"firstname":"Castillo","lastname":"Dickerson","age":33,"gender":"F","address":"673 Oxford Street","employer":"Tellifly","email":"castillodickerson@tellifly.com","city":"Succasunna","state":"NY"} -{"account_number":112,"balance":38395,"firstname":"Frederick","lastname":"Case","age":30,"gender":"F","address":"580 Lexington Avenue","employer":"Talkalot","email":"frederickcase@talkalot.com","city":"Orovada","state":"MA"} -{"account_number":117,"balance":48831,"firstname":"Robin","lastname":"Hays","age":38,"gender":"F","address":"347 Hornell Loop","employer":"Pasturia","email":"robinhays@pasturia.com","city":"Sims","state":"WY"} -{"account_number":124,"balance":16425,"firstname":"Fern","lastname":"Lambert","age":20,"gender":"M","address":"511 Jay Street","employer":"Furnitech","email":"fernlambert@furnitech.com","city":"Cloverdale","state":"FL"} -{"account_number":129,"balance":42409,"firstname":"Alexandria","lastname":"Sanford","age":33,"gender":"F","address":"934 Ridgecrest Terrace","employer":"Kyagoro","email":"alexandriasanford@kyagoro.com","city":"Concho","state":"UT"} -{"account_number":131,"balance":28030,"firstname":"Dollie","lastname":"Koch","age":22,"gender":"F","address":"287 Manhattan Avenue","employer":"Skinserve","email":"dolliekoch@skinserve.com","city":"Shasta","state":"PA"} -{"account_number":136,"balance":45801,"firstname":"Winnie","lastname":"Holland","age":38,"gender":"M","address":"198 Mill Lane","employer":"Neteria","email":"winnieholland@neteria.com","city":"Urie","state":"IL"} -{"account_number":143,"balance":43093,"firstname":"Cohen","lastname":"Noble","age":39,"gender":"M","address":"454 Nelson Street","employer":"Buzzworks","email":"cohennoble@buzzworks.com","city":"Norvelt","state":"CO"} -{"account_number":148,"balance":3662,"firstname":"Annmarie","lastname":"Snider","age":34,"gender":"F","address":"857 Lafayette Walk","employer":"Edecine","email":"annmariesnider@edecine.com","city":"Hollins","state":"OH"} -{"account_number":150,"balance":15306,"firstname":"Ortega","lastname":"Dalton","age":20,"gender":"M","address":"237 Mermaid Avenue","employer":"Rameon","email":"ortegadalton@rameon.com","city":"Maxville","state":"NH"} -{"account_number":155,"balance":27878,"firstname":"Atkinson","lastname":"Hudson","age":39,"gender":"F","address":"434 Colin Place","employer":"Qualitern","email":"atkinsonhudson@qualitern.com","city":"Hoehne","state":"OH"} -{"account_number":162,"balance":6302,"firstname":"Griffith","lastname":"Calderon","age":35,"gender":"M","address":"871 Vandervoort Place","employer":"Quotezart","email":"griffithcalderon@quotezart.com","city":"Barclay","state":"FL"} -{"account_number":167,"balance":42051,"firstname":"Hampton","lastname":"Ryan","age":20,"gender":"M","address":"618 Fleet Place","employer":"Zipak","email":"hamptonryan@zipak.com","city":"Irwin","state":"KS"} -{"account_number":174,"balance":1464,"firstname":"Gamble","lastname":"Pierce","age":23,"gender":"F","address":"650 Eagle Street","employer":"Matrixity","email":"gamblepierce@matrixity.com","city":"Abiquiu","state":"OR"} -{"account_number":179,"balance":13265,"firstname":"Elise","lastname":"Drake","age":25,"gender":"M","address":"305 Christopher Avenue","employer":"Turnling","email":"elisedrake@turnling.com","city":"Loretto","state":"LA"} -{"account_number":181,"balance":27983,"firstname":"Bennett","lastname":"Hampton","age":22,"gender":"F","address":"435 Billings Place","employer":"Voipa","email":"bennetthampton@voipa.com","city":"Rodman","state":"WY"} -{"account_number":186,"balance":18373,"firstname":"Kline","lastname":"Joyce","age":32,"gender":"M","address":"285 Falmouth Street","employer":"Tetratrex","email":"klinejoyce@tetratrex.com","city":"Klondike","state":"SD"} -{"account_number":193,"balance":13412,"firstname":"Patty","lastname":"Petty","age":34,"gender":"F","address":"251 Vermont Street","employer":"Kinetica","email":"pattypetty@kinetica.com","city":"Grantville","state":"MS"} -{"account_number":198,"balance":19686,"firstname":"Rachael","lastname":"Sharp","age":38,"gender":"F","address":"443 Vernon Avenue","employer":"Powernet","email":"rachaelsharp@powernet.com","city":"Canoochee","state":"UT"} -{"account_number":201,"balance":14586,"firstname":"Ronda","lastname":"Perry","age":25,"gender":"F","address":"856 Downing Street","employer":"Artiq","email":"rondaperry@artiq.com","city":"Colton","state":"WV"} -{"account_number":206,"balance":47423,"firstname":"Kelli","lastname":"Francis","age":20,"gender":"M","address":"671 George Street","employer":"Exoswitch","email":"kellifrancis@exoswitch.com","city":"Babb","state":"NJ"} -{"account_number":213,"balance":34172,"firstname":"Bauer","lastname":"Summers","age":27,"gender":"M","address":"257 Boynton Place","employer":"Voratak","email":"bauersummers@voratak.com","city":"Oceola","state":"NC"} -{"account_number":218,"balance":26702,"firstname":"Garrison","lastname":"Bryan","age":24,"gender":"F","address":"478 Greenpoint Avenue","employer":"Uniworld","email":"garrisonbryan@uniworld.com","city":"Comptche","state":"WI"} -{"account_number":220,"balance":3086,"firstname":"Tania","lastname":"Middleton","age":22,"gender":"F","address":"541 Gunther Place","employer":"Zerology","email":"taniamiddleton@zerology.com","city":"Linwood","state":"IN"} -{"account_number":225,"balance":21949,"firstname":"Maryann","lastname":"Murphy","age":24,"gender":"F","address":"894 Bridgewater Street","employer":"Cinesanct","email":"maryannmurphy@cinesanct.com","city":"Cartwright","state":"RI"} -{"account_number":232,"balance":11984,"firstname":"Carr","lastname":"Jensen","age":34,"gender":"F","address":"995 Micieli Place","employer":"Biohab","email":"carrjensen@biohab.com","city":"Waikele","state":"OH"} -{"account_number":237,"balance":5603,"firstname":"Kirby","lastname":"Watkins","age":27,"gender":"F","address":"348 Blake Court","employer":"Sonique","email":"kirbywatkins@sonique.com","city":"Freelandville","state":"PA"} -{"account_number":244,"balance":8048,"firstname":"Judith","lastname":"Riggs","age":27,"gender":"F","address":"590 Kosciusko Street","employer":"Arctiq","email":"judithriggs@arctiq.com","city":"Gorham","state":"DC"} -{"account_number":249,"balance":16822,"firstname":"Mckinney","lastname":"Gallagher","age":38,"gender":"F","address":"939 Seigel Court","employer":"Premiant","email":"mckinneygallagher@premiant.com","city":"Catharine","state":"NH"} -{"account_number":251,"balance":13475,"firstname":"Marks","lastname":"Graves","age":39,"gender":"F","address":"427 Lawn Court","employer":"Dentrex","email":"marksgraves@dentrex.com","city":"Waukeenah","state":"IL"} -{"account_number":256,"balance":48318,"firstname":"Simon","lastname":"Hogan","age":31,"gender":"M","address":"789 Suydam Place","employer":"Dancerity","email":"simonhogan@dancerity.com","city":"Dargan","state":"GA"} -{"account_number":263,"balance":12837,"firstname":"Thornton","lastname":"Meyer","age":29,"gender":"M","address":"575 Elliott Place","employer":"Peticular","email":"thorntonmeyer@peticular.com","city":"Dotsero","state":"NH"} -{"account_number":268,"balance":20925,"firstname":"Avis","lastname":"Blackwell","age":36,"gender":"M","address":"569 Jerome Avenue","employer":"Magnina","email":"avisblackwell@magnina.com","city":"Bethany","state":"MD"} -{"account_number":270,"balance":43951,"firstname":"Moody","lastname":"Harmon","age":39,"gender":"F","address":"233 Vanderbilt Street","employer":"Otherside","email":"moodyharmon@otherside.com","city":"Elwood","state":"MT"} -{"account_number":275,"balance":2384,"firstname":"Reynolds","lastname":"Barnett","age":31,"gender":"M","address":"394 Stockton Street","employer":"Austex","email":"reynoldsbarnett@austex.com","city":"Grandview","state":"MS"} -{"account_number":282,"balance":38540,"firstname":"Gay","lastname":"Schultz","age":25,"gender":"F","address":"805 Claver Place","employer":"Handshake","email":"gayschultz@handshake.com","city":"Tampico","state":"MA"} -{"account_number":287,"balance":10845,"firstname":"Valerie","lastname":"Lang","age":35,"gender":"F","address":"423 Midwood Street","employer":"Quarx","email":"valerielang@quarx.com","city":"Cannondale","state":"VT"} -{"account_number":294,"balance":29582,"firstname":"Pitts","lastname":"Haynes","age":26,"gender":"M","address":"901 Broome Street","employer":"Aquazure","email":"pittshaynes@aquazure.com","city":"Turah","state":"SD"} -{"account_number":299,"balance":40825,"firstname":"Angela","lastname":"Talley","age":36,"gender":"F","address":"822 Bills Place","employer":"Remold","email":"angelatalley@remold.com","city":"Bethpage","state":"DC"} -{"account_number":302,"balance":11298,"firstname":"Isabella","lastname":"Hewitt","age":40,"gender":"M","address":"455 Bedford Avenue","employer":"Cincyr","email":"isabellahewitt@cincyr.com","city":"Blanford","state":"IN"} -{"account_number":307,"balance":43355,"firstname":"Enid","lastname":"Ashley","age":23,"gender":"M","address":"412 Emerson Place","employer":"Avenetro","email":"enidashley@avenetro.com","city":"Catherine","state":"WI"} -{"account_number":314,"balance":5848,"firstname":"Norton","lastname":"Norton","age":35,"gender":"M","address":"252 Ditmas Avenue","employer":"Talkola","email":"nortonnorton@talkola.com","city":"Veyo","state":"SC"} -{"account_number":319,"balance":15430,"firstname":"Ferrell","lastname":"Mckinney","age":36,"gender":"M","address":"874 Cranberry Street","employer":"Portaline","email":"ferrellmckinney@portaline.com","city":"Rose","state":"WV"} -{"account_number":321,"balance":43370,"firstname":"Marta","lastname":"Larsen","age":35,"gender":"M","address":"617 Williams Court","employer":"Manufact","email":"martalarsen@manufact.com","city":"Sisquoc","state":"MA"} -{"account_number":326,"balance":9692,"firstname":"Pearl","lastname":"Reese","age":30,"gender":"F","address":"451 Colonial Court","employer":"Accruex","email":"pearlreese@accruex.com","city":"Westmoreland","state":"MD"} -{"account_number":333,"balance":22778,"firstname":"Trudy","lastname":"Sweet","age":27,"gender":"F","address":"881 Kiely Place","employer":"Acumentor","email":"trudysweet@acumentor.com","city":"Kent","state":"IA"} -{"account_number":338,"balance":6969,"firstname":"Pierce","lastname":"Lawrence","age":35,"gender":"M","address":"318 Gallatin Place","employer":"Lunchpad","email":"piercelawrence@lunchpad.com","city":"Iola","state":"MD"} -{"account_number":340,"balance":42072,"firstname":"Juarez","lastname":"Gutierrez","age":40,"gender":"F","address":"802 Seba Avenue","employer":"Billmed","email":"juarezgutierrez@billmed.com","city":"Malott","state":"OH"} -{"account_number":345,"balance":9812,"firstname":"Parker","lastname":"Hines","age":38,"gender":"M","address":"715 Mill Avenue","employer":"Baluba","email":"parkerhines@baluba.com","city":"Blackgum","state":"KY"} -{"account_number":352,"balance":20290,"firstname":"Kendra","lastname":"Mcintosh","age":31,"gender":"F","address":"963 Wolf Place","employer":"Orboid","email":"kendramcintosh@orboid.com","city":"Bladensburg","state":"AK"} -{"account_number":357,"balance":15102,"firstname":"Adele","lastname":"Carroll","age":39,"gender":"F","address":"381 Arion Place","employer":"Aquafire","email":"adelecarroll@aquafire.com","city":"Springville","state":"RI"} -{"account_number":364,"balance":35247,"firstname":"Felicia","lastname":"Merrill","age":40,"gender":"F","address":"229 Branton Street","employer":"Prosely","email":"feliciamerrill@prosely.com","city":"Dola","state":"MA"} -{"account_number":369,"balance":17047,"firstname":"Mcfadden","lastname":"Guy","age":28,"gender":"F","address":"445 Lott Avenue","employer":"Kangle","email":"mcfaddenguy@kangle.com","city":"Greenbackville","state":"DE"} -{"account_number":371,"balance":19751,"firstname":"Barker","lastname":"Allen","age":32,"gender":"F","address":"295 Wallabout Street","employer":"Nexgene","email":"barkerallen@nexgene.com","city":"Nanafalia","state":"NE"} -{"account_number":376,"balance":44407,"firstname":"Mcmillan","lastname":"Dunn","age":21,"gender":"F","address":"771 Dorchester Road","employer":"Eargo","email":"mcmillandunn@eargo.com","city":"Yogaville","state":"RI"} -{"account_number":383,"balance":48889,"firstname":"Knox","lastname":"Larson","age":28,"gender":"F","address":"962 Bartlett Place","employer":"Bostonic","email":"knoxlarson@bostonic.com","city":"Smeltertown","state":"TX"} -{"account_number":388,"balance":9606,"firstname":"Julianne","lastname":"Nicholson","age":26,"gender":"F","address":"338 Crescent Street","employer":"Viasia","email":"juliannenicholson@viasia.com","city":"Alleghenyville","state":"MO"} -{"account_number":390,"balance":7464,"firstname":"Ramona","lastname":"Roy","age":32,"gender":"M","address":"135 Banner Avenue","employer":"Deminimum","email":"ramonaroy@deminimum.com","city":"Dodge","state":"ID"} -{"account_number":395,"balance":18679,"firstname":"Juliet","lastname":"Whitaker","age":31,"gender":"M","address":"128 Remsen Avenue","employer":"Toyletry","email":"julietwhitaker@toyletry.com","city":"Yonah","state":"LA"} -{"account_number":403,"balance":18833,"firstname":"Williamson","lastname":"Horn","age":32,"gender":"M","address":"223 Strickland Avenue","employer":"Nimon","email":"williamsonhorn@nimon.com","city":"Bawcomville","state":"NJ"} -{"account_number":408,"balance":34666,"firstname":"Lidia","lastname":"Guerrero","age":30,"gender":"M","address":"254 Stratford Road","employer":"Snowpoke","email":"lidiaguerrero@snowpoke.com","city":"Fairlee","state":"LA"} -{"account_number":410,"balance":31200,"firstname":"Fox","lastname":"Cardenas","age":39,"gender":"M","address":"987 Monitor Street","employer":"Corpulse","email":"foxcardenas@corpulse.com","city":"Southview","state":"NE"} -{"account_number":415,"balance":19449,"firstname":"Martinez","lastname":"Benson","age":36,"gender":"M","address":"172 Berkeley Place","employer":"Enersol","email":"martinezbenson@enersol.com","city":"Chumuckla","state":"AL"} -{"account_number":422,"balance":40162,"firstname":"Brigitte","lastname":"Scott","age":26,"gender":"M","address":"662 Vermont Court","employer":"Waretel","email":"brigittescott@waretel.com","city":"Elrama","state":"VA"} -{"account_number":427,"balance":1463,"firstname":"Rebekah","lastname":"Garrison","age":36,"gender":"F","address":"837 Hampton Avenue","employer":"Niquent","email":"rebekahgarrison@niquent.com","city":"Zarephath","state":"NY"} -{"account_number":434,"balance":11329,"firstname":"Christa","lastname":"Huff","age":25,"gender":"M","address":"454 Oriental Boulevard","employer":"Earthpure","email":"christahuff@earthpure.com","city":"Stevens","state":"DC"} -{"account_number":439,"balance":22752,"firstname":"Lula","lastname":"Williams","age":35,"gender":"M","address":"630 Furman Avenue","employer":"Vinch","email":"lulawilliams@vinch.com","city":"Newcastle","state":"ME"} -{"account_number":441,"balance":47947,"firstname":"Dickson","lastname":"Mcgee","age":29,"gender":"M","address":"478 Knight Court","employer":"Gogol","email":"dicksonmcgee@gogol.com","city":"Laurelton","state":"AR"} -{"account_number":446,"balance":23071,"firstname":"Lolita","lastname":"Fleming","age":32,"gender":"F","address":"918 Bridge Street","employer":"Vidto","email":"lolitafleming@vidto.com","city":"Brownlee","state":"HI"} -{"account_number":453,"balance":21520,"firstname":"Hood","lastname":"Powell","age":24,"gender":"F","address":"479 Brevoort Place","employer":"Vortexaco","email":"hoodpowell@vortexaco.com","city":"Alderpoint","state":"CT"} -{"account_number":458,"balance":8865,"firstname":"Aida","lastname":"Wolf","age":21,"gender":"F","address":"403 Thames Street","employer":"Isis","email":"aidawolf@isis.com","city":"Bordelonville","state":"ME"} -{"account_number":460,"balance":37734,"firstname":"Aguirre","lastname":"White","age":21,"gender":"F","address":"190 Crooke Avenue","employer":"Unq","email":"aguirrewhite@unq.com","city":"Albany","state":"NJ"} -{"account_number":465,"balance":10681,"firstname":"Pearlie","lastname":"Holman","age":29,"gender":"M","address":"916 Evergreen Avenue","employer":"Hometown","email":"pearlieholman@hometown.com","city":"Needmore","state":"UT"} -{"account_number":472,"balance":25571,"firstname":"Lee","lastname":"Long","age":32,"gender":"F","address":"288 Mill Street","employer":"Comverges","email":"leelong@comverges.com","city":"Movico","state":"MT"} -{"account_number":477,"balance":25892,"firstname":"Holcomb","lastname":"Cobb","age":40,"gender":"M","address":"369 Marconi Place","employer":"Steeltab","email":"holcombcobb@steeltab.com","city":"Byrnedale","state":"CA"} -{"account_number":484,"balance":3274,"firstname":"Staci","lastname":"Melendez","age":35,"gender":"F","address":"751 Otsego Street","employer":"Namebox","email":"stacimelendez@namebox.com","city":"Harborton","state":"NV"} -{"account_number":489,"balance":7879,"firstname":"Garrett","lastname":"Langley","age":36,"gender":"M","address":"331 Bowne Street","employer":"Zillidium","email":"garrettlangley@zillidium.com","city":"Riviera","state":"LA"} -{"account_number":491,"balance":42942,"firstname":"Teresa","lastname":"Owen","age":24,"gender":"F","address":"713 Canton Court","employer":"Plasmos","email":"teresaowen@plasmos.com","city":"Bartonsville","state":"NH"} -{"account_number":496,"balance":14869,"firstname":"Alison","lastname":"Conrad","age":35,"gender":"F","address":"347 Varet Street","employer":"Perkle","email":"alisonconrad@perkle.com","city":"Cliffside","state":"OH"} -{"account_number":504,"balance":49205,"firstname":"Shanna","lastname":"Chambers","age":23,"gender":"M","address":"220 Beard Street","employer":"Corporana","email":"shannachambers@corporana.com","city":"Cashtown","state":"AZ"} -{"account_number":509,"balance":34754,"firstname":"Durham","lastname":"Pacheco","age":40,"gender":"M","address":"129 Plymouth Street","employer":"Datacator","email":"durhampacheco@datacator.com","city":"Loveland","state":"NC"} -{"account_number":511,"balance":40908,"firstname":"Elba","lastname":"Grant","age":24,"gender":"F","address":"157 Bijou Avenue","employer":"Dognost","email":"elbagrant@dognost.com","city":"Coyote","state":"MT"} -{"account_number":516,"balance":44940,"firstname":"Roy","lastname":"Smith","age":37,"gender":"M","address":"770 Cherry Street","employer":"Parleynet","email":"roysmith@parleynet.com","city":"Carrsville","state":"RI"} -{"account_number":523,"balance":28729,"firstname":"Amalia","lastname":"Benjamin","age":40,"gender":"F","address":"173 Bushwick Place","employer":"Sentia","email":"amaliabenjamin@sentia.com","city":"Jacumba","state":"OK"} -{"account_number":528,"balance":4071,"firstname":"Thompson","lastname":"Hoover","age":27,"gender":"F","address":"580 Garden Street","employer":"Portalis","email":"thompsonhoover@portalis.com","city":"Knowlton","state":"AL"} -{"account_number":530,"balance":8840,"firstname":"Kathrine","lastname":"Evans","age":37,"gender":"M","address":"422 Division Place","employer":"Spherix","email":"kathrineevans@spherix.com","city":"Biddle","state":"CO"} -{"account_number":535,"balance":8715,"firstname":"Fry","lastname":"George","age":34,"gender":"M","address":"722 Green Street","employer":"Ewaves","email":"frygeorge@ewaves.com","city":"Kenmar","state":"DE"} -{"account_number":542,"balance":23285,"firstname":"Michelle","lastname":"Mayo","age":35,"gender":"M","address":"657 Caton Place","employer":"Biflex","email":"michellemayo@biflex.com","city":"Beaverdale","state":"WY"} -{"account_number":547,"balance":12870,"firstname":"Eaton","lastname":"Rios","age":32,"gender":"M","address":"744 Withers Street","employer":"Podunk","email":"eatonrios@podunk.com","city":"Chelsea","state":"IA"} -{"account_number":554,"balance":33163,"firstname":"Townsend","lastname":"Atkins","age":39,"gender":"M","address":"566 Ira Court","employer":"Acruex","email":"townsendatkins@acruex.com","city":"Valle","state":"IA"} -{"account_number":559,"balance":11450,"firstname":"Tonia","lastname":"Schmidt","age":38,"gender":"F","address":"508 Sheffield Avenue","employer":"Extro","email":"toniaschmidt@extro.com","city":"Newry","state":"CT"} -{"account_number":561,"balance":12370,"firstname":"Sellers","lastname":"Davis","age":30,"gender":"M","address":"860 Madoc Avenue","employer":"Isodrive","email":"sellersdavis@isodrive.com","city":"Trail","state":"KS"} -{"account_number":566,"balance":6183,"firstname":"Cox","lastname":"Roman","age":37,"gender":"M","address":"349 Winthrop Street","employer":"Medcom","email":"coxroman@medcom.com","city":"Rosewood","state":"WY"} -{"account_number":573,"balance":32171,"firstname":"Callie","lastname":"Castaneda","age":36,"gender":"M","address":"799 Scott Avenue","employer":"Earthwax","email":"calliecastaneda@earthwax.com","city":"Marshall","state":"NH"} -{"account_number":578,"balance":34259,"firstname":"Holmes","lastname":"Mcknight","age":37,"gender":"M","address":"969 Metropolitan Avenue","employer":"Cubicide","email":"holmesmcknight@cubicide.com","city":"Aguila","state":"PA"} -{"account_number":580,"balance":13716,"firstname":"Mcmahon","lastname":"York","age":34,"gender":"M","address":"475 Beacon Court","employer":"Zillar","email":"mcmahonyork@zillar.com","city":"Farmington","state":"MO"} -{"account_number":585,"balance":26745,"firstname":"Nieves","lastname":"Nolan","age":32,"gender":"M","address":"115 Seagate Terrace","employer":"Jumpstack","email":"nievesnolan@jumpstack.com","city":"Eastmont","state":"UT"} -{"account_number":592,"balance":32968,"firstname":"Head","lastname":"Webster","age":36,"gender":"F","address":"987 Lefferts Avenue","employer":"Empirica","email":"headwebster@empirica.com","city":"Rockingham","state":"TN"} -{"account_number":597,"balance":11246,"firstname":"Penny","lastname":"Knowles","age":33,"gender":"M","address":"139 Forbell Street","employer":"Ersum","email":"pennyknowles@ersum.com","city":"Vallonia","state":"IA"} -{"account_number":600,"balance":10336,"firstname":"Simmons","lastname":"Byers","age":37,"gender":"M","address":"250 Dictum Court","employer":"Qualitex","email":"simmonsbyers@qualitex.com","city":"Wanship","state":"OH"} -{"account_number":605,"balance":38427,"firstname":"Mcclain","lastname":"Manning","age":24,"gender":"M","address":"832 Leonard Street","employer":"Qiao","email":"mcclainmanning@qiao.com","city":"Calvary","state":"TX"} -{"account_number":612,"balance":11868,"firstname":"Dunn","lastname":"Cameron","age":32,"gender":"F","address":"156 Lorimer Street","employer":"Isonus","email":"dunncameron@isonus.com","city":"Virgie","state":"ND"} -{"account_number":617,"balance":35445,"firstname":"Kitty","lastname":"Cooley","age":22,"gender":"M","address":"788 Seagate Avenue","employer":"Ultrimax","email":"kittycooley@ultrimax.com","city":"Clarktown","state":"MD"} -{"account_number":624,"balance":27538,"firstname":"Roxanne","lastname":"Franklin","age":39,"gender":"F","address":"299 Woodrow Court","employer":"Silodyne","email":"roxannefranklin@silodyne.com","city":"Roulette","state":"VA"} -{"account_number":629,"balance":32987,"firstname":"Mcclure","lastname":"Rodgers","age":26,"gender":"M","address":"806 Pierrepont Place","employer":"Elita","email":"mcclurerodgers@elita.com","city":"Brownsville","state":"MI"} -{"account_number":631,"balance":21657,"firstname":"Corrine","lastname":"Barber","age":32,"gender":"F","address":"447 Hunts Lane","employer":"Quarmony","email":"corrinebarber@quarmony.com","city":"Wyano","state":"IL"} -{"account_number":636,"balance":8036,"firstname":"Agnes","lastname":"Hooper","age":25,"gender":"M","address":"865 Hanson Place","employer":"Digial","email":"agneshooper@digial.com","city":"Sperryville","state":"OK"} -{"account_number":643,"balance":8057,"firstname":"Hendricks","lastname":"Stokes","age":23,"gender":"F","address":"142 Barbey Street","employer":"Remotion","email":"hendricksstokes@remotion.com","city":"Lewis","state":"MA"} -{"account_number":648,"balance":11506,"firstname":"Terry","lastname":"Montgomery","age":21,"gender":"F","address":"115 Franklin Avenue","employer":"Enervate","email":"terrymontgomery@enervate.com","city":"Bascom","state":"MA"} -{"account_number":650,"balance":18091,"firstname":"Benton","lastname":"Knight","age":28,"gender":"F","address":"850 Aitken Place","employer":"Pholio","email":"bentonknight@pholio.com","city":"Cobbtown","state":"AL"} -{"account_number":655,"balance":22912,"firstname":"Eula","lastname":"Taylor","age":30,"gender":"M","address":"520 Orient Avenue","employer":"Miracula","email":"eulataylor@miracula.com","city":"Wacissa","state":"IN"} -{"account_number":662,"balance":10138,"firstname":"Daisy","lastname":"Burnett","age":33,"gender":"M","address":"114 Norman Avenue","employer":"Liquicom","email":"daisyburnett@liquicom.com","city":"Grahamtown","state":"MD"} -{"account_number":667,"balance":22559,"firstname":"Juliana","lastname":"Chase","age":32,"gender":"M","address":"496 Coleridge Street","employer":"Comtract","email":"julianachase@comtract.com","city":"Wilsonia","state":"NJ"} -{"account_number":674,"balance":36038,"firstname":"Watts","lastname":"Shannon","age":22,"gender":"F","address":"600 Story Street","employer":"Joviold","email":"wattsshannon@joviold.com","city":"Fairhaven","state":"ID"} -{"account_number":679,"balance":20149,"firstname":"Henrietta","lastname":"Bonner","age":33,"gender":"M","address":"461 Bond Street","employer":"Geekol","email":"henriettabonner@geekol.com","city":"Richville","state":"WA"} -{"account_number":681,"balance":34244,"firstname":"Velazquez","lastname":"Wolfe","age":33,"gender":"M","address":"773 Eckford Street","employer":"Zisis","email":"velazquezwolfe@zisis.com","city":"Smock","state":"ME"} -{"account_number":686,"balance":10116,"firstname":"Decker","lastname":"Mcclure","age":30,"gender":"F","address":"236 Commerce Street","employer":"Everest","email":"deckermcclure@everest.com","city":"Gibbsville","state":"TN"} -{"account_number":693,"balance":31233,"firstname":"Tabatha","lastname":"Zimmerman","age":30,"gender":"F","address":"284 Emmons Avenue","employer":"Pushcart","email":"tabathazimmerman@pushcart.com","city":"Esmont","state":"NC"} -{"account_number":698,"balance":14965,"firstname":"Baker","lastname":"Armstrong","age":36,"gender":"F","address":"796 Tehama Street","employer":"Nurplex","email":"bakerarmstrong@nurplex.com","city":"Starks","state":"UT"} -{"account_number":701,"balance":23772,"firstname":"Gardner","lastname":"Griffith","age":27,"gender":"M","address":"187 Moore Place","employer":"Vertide","email":"gardnergriffith@vertide.com","city":"Coventry","state":"NV"} -{"account_number":706,"balance":5282,"firstname":"Eliza","lastname":"Potter","age":39,"gender":"M","address":"945 Dunham Place","employer":"Playce","email":"elizapotter@playce.com","city":"Woodruff","state":"AK"} -{"account_number":713,"balance":20054,"firstname":"Iris","lastname":"Mcguire","age":21,"gender":"F","address":"508 Benson Avenue","employer":"Duflex","email":"irismcguire@duflex.com","city":"Hillsboro","state":"MO"} -{"account_number":718,"balance":13876,"firstname":"Hickman","lastname":"Dillard","age":22,"gender":"F","address":"132 Etna Street","employer":"Genmy","email":"hickmandillard@genmy.com","city":"Curtice","state":"NV"} -{"account_number":720,"balance":31356,"firstname":"Ruth","lastname":"Vance","age":32,"gender":"F","address":"229 Adams Street","employer":"Zilidium","email":"ruthvance@zilidium.com","city":"Allison","state":"IA"} -{"account_number":725,"balance":14677,"firstname":"Reeves","lastname":"Tillman","age":26,"gender":"M","address":"674 Ivan Court","employer":"Cemention","email":"reevestillman@cemention.com","city":"Navarre","state":"MA"} -{"account_number":732,"balance":38445,"firstname":"Delia","lastname":"Cruz","age":37,"gender":"F","address":"870 Cheever Place","employer":"Multron","email":"deliacruz@multron.com","city":"Cresaptown","state":"NH"} -{"account_number":737,"balance":40431,"firstname":"Sampson","lastname":"Yates","age":23,"gender":"F","address":"214 Cox Place","employer":"Signidyne","email":"sampsonyates@signidyne.com","city":"Brazos","state":"GA"} -{"account_number":744,"balance":8690,"firstname":"Bernard","lastname":"Martinez","age":21,"gender":"M","address":"148 Dunne Place","employer":"Dragbot","email":"bernardmartinez@dragbot.com","city":"Moraida","state":"MN"} -{"account_number":749,"balance":1249,"firstname":"Rush","lastname":"Boyle","age":36,"gender":"M","address":"310 Argyle Road","employer":"Sportan","email":"rushboyle@sportan.com","city":"Brady","state":"WA"} -{"account_number":751,"balance":49252,"firstname":"Patrick","lastname":"Osborne","age":23,"gender":"M","address":"915 Prospect Avenue","employer":"Gynko","email":"patrickosborne@gynko.com","city":"Takilma","state":"MO"} -{"account_number":756,"balance":40006,"firstname":"Jasmine","lastname":"Howell","age":32,"gender":"M","address":"605 Elliott Walk","employer":"Ecratic","email":"jasminehowell@ecratic.com","city":"Harrodsburg","state":"OH"} -{"account_number":763,"balance":12091,"firstname":"Liz","lastname":"Bentley","age":22,"gender":"F","address":"933 Debevoise Avenue","employer":"Nipaz","email":"lizbentley@nipaz.com","city":"Glenville","state":"NJ"} -{"account_number":768,"balance":2213,"firstname":"Sondra","lastname":"Soto","age":21,"gender":"M","address":"625 Colonial Road","employer":"Navir","email":"sondrasoto@navir.com","city":"Benson","state":"VA"} -{"account_number":770,"balance":39505,"firstname":"Joann","lastname":"Crane","age":26,"gender":"M","address":"798 Farragut Place","employer":"Lingoage","email":"joanncrane@lingoage.com","city":"Kirk","state":"MA"} -{"account_number":775,"balance":27943,"firstname":"Wilson","lastname":"Merritt","age":33,"gender":"F","address":"288 Thornton Street","employer":"Geeky","email":"wilsonmerritt@geeky.com","city":"Holtville","state":"HI"} -{"account_number":782,"balance":3960,"firstname":"Maldonado","lastname":"Craig","age":36,"gender":"F","address":"345 Myrtle Avenue","employer":"Zilencio","email":"maldonadocraig@zilencio.com","city":"Yukon","state":"ID"} -{"account_number":787,"balance":11876,"firstname":"Harper","lastname":"Wynn","age":21,"gender":"F","address":"139 Oceanic Avenue","employer":"Interfind","email":"harperwynn@interfind.com","city":"Gerber","state":"ND"} -{"account_number":794,"balance":16491,"firstname":"Walker","lastname":"Charles","age":32,"gender":"M","address":"215 Kenilworth Place","employer":"Orbin","email":"walkercharles@orbin.com","city":"Rivers","state":"WI"} -{"account_number":799,"balance":2889,"firstname":"Myra","lastname":"Guerra","age":28,"gender":"F","address":"625 Dahlgreen Place","employer":"Digigene","email":"myraguerra@digigene.com","city":"Draper","state":"CA"} -{"account_number":802,"balance":19630,"firstname":"Gracie","lastname":"Foreman","age":40,"gender":"F","address":"219 Kent Avenue","employer":"Supportal","email":"gracieforeman@supportal.com","city":"Westboro","state":"NH"} -{"account_number":807,"balance":29206,"firstname":"Hatfield","lastname":"Lowe","age":23,"gender":"M","address":"499 Adler Place","employer":"Lovepad","email":"hatfieldlowe@lovepad.com","city":"Wiscon","state":"DC"} -{"account_number":814,"balance":9838,"firstname":"Morse","lastname":"Mcbride","age":26,"gender":"F","address":"776 Calyer Street","employer":"Inear","email":"morsemcbride@inear.com","city":"Kingstowne","state":"ND"} -{"account_number":819,"balance":3971,"firstname":"Karyn","lastname":"Medina","age":24,"gender":"F","address":"417 Utica Avenue","employer":"Qnekt","email":"karynmedina@qnekt.com","city":"Kerby","state":"WY"} -{"account_number":821,"balance":33271,"firstname":"Trisha","lastname":"Blankenship","age":22,"gender":"M","address":"329 Jamaica Avenue","employer":"Chorizon","email":"trishablankenship@chorizon.com","city":"Sexton","state":"VT"} -{"account_number":826,"balance":11548,"firstname":"Summers","lastname":"Vinson","age":22,"gender":"F","address":"742 Irwin Street","employer":"Globoil","email":"summersvinson@globoil.com","city":"Callaghan","state":"WY"} -{"account_number":833,"balance":46154,"firstname":"Woodward","lastname":"Hood","age":22,"gender":"M","address":"398 Atkins Avenue","employer":"Zedalis","email":"woodwardhood@zedalis.com","city":"Stonybrook","state":"NE"} -{"account_number":838,"balance":24629,"firstname":"Latonya","lastname":"Blake","age":37,"gender":"F","address":"531 Milton Street","employer":"Rugstars","email":"latonyablake@rugstars.com","city":"Tedrow","state":"WA"} -{"account_number":840,"balance":39615,"firstname":"Boone","lastname":"Gomez","age":38,"gender":"M","address":"256 Hampton Place","employer":"Geekular","email":"boonegomez@geekular.com","city":"Westerville","state":"HI"} -{"account_number":845,"balance":35422,"firstname":"Tracy","lastname":"Vaughn","age":39,"gender":"M","address":"645 Rockaway Parkway","employer":"Andryx","email":"tracyvaughn@andryx.com","city":"Wilmington","state":"ME"} -{"account_number":852,"balance":6041,"firstname":"Allen","lastname":"Hammond","age":26,"gender":"M","address":"793 Essex Street","employer":"Tersanki","email":"allenhammond@tersanki.com","city":"Osmond","state":"NC"} -{"account_number":857,"balance":39678,"firstname":"Alyce","lastname":"Douglas","age":23,"gender":"M","address":"326 Robert Street","employer":"Earbang","email":"alycedouglas@earbang.com","city":"Thornport","state":"GA"} -{"account_number":864,"balance":21804,"firstname":"Duffy","lastname":"Anthony","age":23,"gender":"M","address":"582 Cooke Court","employer":"Schoolio","email":"duffyanthony@schoolio.com","city":"Brenton","state":"CO"} -{"account_number":869,"balance":43544,"firstname":"Corinne","lastname":"Robbins","age":25,"gender":"F","address":"732 Quentin Road","employer":"Orbaxter","email":"corinnerobbins@orbaxter.com","city":"Roy","state":"TN"} -{"account_number":871,"balance":35854,"firstname":"Norma","lastname":"Burt","age":32,"gender":"M","address":"934 Cyrus Avenue","employer":"Magnafone","email":"normaburt@magnafone.com","city":"Eden","state":"TN"} -{"account_number":876,"balance":48568,"firstname":"Brady","lastname":"Glover","age":21,"gender":"F","address":"565 Oceanview Avenue","employer":"Comvex","email":"bradyglover@comvex.com","city":"Noblestown","state":"ID"} -{"account_number":883,"balance":33679,"firstname":"Austin","lastname":"Jefferson","age":34,"gender":"M","address":"846 Lincoln Avenue","employer":"Polarax","email":"austinjefferson@polarax.com","city":"Savannah","state":"CT"} -{"account_number":888,"balance":22277,"firstname":"Myrna","lastname":"Herman","age":39,"gender":"F","address":"649 Harwood Place","employer":"Enthaze","email":"myrnaherman@enthaze.com","city":"Idamay","state":"AR"} -{"account_number":890,"balance":31198,"firstname":"Alvarado","lastname":"Pate","age":25,"gender":"M","address":"269 Ashland Place","employer":"Ovolo","email":"alvaradopate@ovolo.com","city":"Volta","state":"MI"} -{"account_number":895,"balance":7327,"firstname":"Lara","lastname":"Mcdaniel","age":36,"gender":"M","address":"854 Willow Place","employer":"Acusage","email":"laramcdaniel@acusage.com","city":"Imperial","state":"NC"} -{"account_number":903,"balance":10238,"firstname":"Wade","lastname":"Page","age":35,"gender":"F","address":"685 Waldorf Court","employer":"Eplosion","email":"wadepage@eplosion.com","city":"Welda","state":"AL"} -{"account_number":908,"balance":45975,"firstname":"Mosley","lastname":"Holloway","age":31,"gender":"M","address":"929 Eldert Lane","employer":"Anivet","email":"mosleyholloway@anivet.com","city":"Biehle","state":"MS"} -{"account_number":910,"balance":36831,"firstname":"Esmeralda","lastname":"James","age":23,"gender":"F","address":"535 High Street","employer":"Terrasys","email":"esmeraldajames@terrasys.com","city":"Dubois","state":"IN"} -{"account_number":915,"balance":19816,"firstname":"Farrell","lastname":"French","age":35,"gender":"F","address":"126 McKibbin Street","employer":"Techmania","email":"farrellfrench@techmania.com","city":"Wescosville","state":"AL"} -{"account_number":922,"balance":39347,"firstname":"Irwin","lastname":"Pugh","age":32,"gender":"M","address":"463 Shale Street","employer":"Idego","email":"irwinpugh@idego.com","city":"Ivanhoe","state":"ID"} -{"account_number":927,"balance":19976,"firstname":"Jeanette","lastname":"Acevedo","age":26,"gender":"M","address":"694 Polhemus Place","employer":"Halap","email":"jeanetteacevedo@halap.com","city":"Harrison","state":"MO"} -{"account_number":934,"balance":43987,"firstname":"Freida","lastname":"Daniels","age":34,"gender":"M","address":"448 Cove Lane","employer":"Vurbo","email":"freidadaniels@vurbo.com","city":"Snelling","state":"NJ"} -{"account_number":939,"balance":31228,"firstname":"Hodges","lastname":"Massey","age":37,"gender":"F","address":"431 Dahl Court","employer":"Kegular","email":"hodgesmassey@kegular.com","city":"Katonah","state":"MD"} -{"account_number":941,"balance":38796,"firstname":"Kim","lastname":"Moss","age":28,"gender":"F","address":"105 Onderdonk Avenue","employer":"Digirang","email":"kimmoss@digirang.com","city":"Centerville","state":"TX"} -{"account_number":946,"balance":42794,"firstname":"Ina","lastname":"Obrien","age":36,"gender":"M","address":"339 Rewe Street","employer":"Eclipsent","email":"inaobrien@eclipsent.com","city":"Soham","state":"RI"} -{"account_number":953,"balance":1110,"firstname":"Baxter","lastname":"Black","age":27,"gender":"M","address":"720 Stillwell Avenue","employer":"Uplinx","email":"baxterblack@uplinx.com","city":"Drummond","state":"MN"} -{"account_number":958,"balance":32849,"firstname":"Brown","lastname":"Wilkins","age":40,"gender":"M","address":"686 Delmonico Place","employer":"Medesign","email":"brownwilkins@medesign.com","city":"Shelby","state":"WY"} -{"account_number":960,"balance":2905,"firstname":"Curry","lastname":"Vargas","age":40,"gender":"M","address":"242 Blake Avenue","employer":"Pearlesex","email":"curryvargas@pearlesex.com","city":"Henrietta","state":"NH"} -{"account_number":965,"balance":21882,"firstname":"Patrica","lastname":"Melton","age":28,"gender":"M","address":"141 Rodney Street","employer":"Flexigen","email":"patricamelton@flexigen.com","city":"Klagetoh","state":"MD"} -{"account_number":972,"balance":24719,"firstname":"Leona","lastname":"Christian","age":26,"gender":"F","address":"900 Woodpoint Road","employer":"Extrawear","email":"leonachristian@extrawear.com","city":"Roderfield","state":"MA"} -{"account_number":977,"balance":6744,"firstname":"Rodgers","lastname":"Mccray","age":21,"gender":"F","address":"612 Duryea Place","employer":"Papricut","email":"rodgersmccray@papricut.com","city":"Marenisco","state":"MD"} -{"account_number":984,"balance":1904,"firstname":"Viola","lastname":"Crawford","age":35,"gender":"F","address":"354 Linwood Street","employer":"Ginkle","email":"violacrawford@ginkle.com","city":"Witmer","state":"AR"} -{"account_number":989,"balance":48622,"firstname":"Franklin","lastname":"Frank","age":38,"gender":"M","address":"270 Carlton Avenue","employer":"Shopabout","email":"franklinfrank@shopabout.com","city":"Guthrie","state":"NC"} -{"account_number":991,"balance":4239,"firstname":"Connie","lastname":"Berry","age":28,"gender":"F","address":"647 Gardner Avenue","employer":"Flumbo","email":"connieberry@flumbo.com","city":"Frierson","state":"MO"} -{"account_number":996,"balance":17541,"firstname":"Andrews","lastname":"Herrera","age":30,"gender":"F","address":"570 Vandam Street","employer":"Klugger","email":"andrewsherrera@klugger.com","city":"Whitehaven","state":"MN"} -{"account_number":0,"balance":16623,"firstname":"Bradshaw","lastname":"Mckenzie","age":29,"gender":"F","address":"244 Columbus Place","employer":"Euron","email":"bradshawmckenzie@euron.com","city":"Hobucken","state":"CO"} -{"account_number":5,"balance":29342,"firstname":"Leola","lastname":"Stewart","age":30,"gender":"F","address":"311 Elm Place","employer":"Diginetic","email":"leolastewart@diginetic.com","city":"Fairview","state":"NJ"} -{"account_number":12,"balance":37055,"firstname":"Stafford","lastname":"Brock","age":20,"gender":"F","address":"296 Wythe Avenue","employer":"Uncorp","email":"staffordbrock@uncorp.com","city":"Bend","state":"AL"} -{"account_number":17,"balance":7831,"firstname":"Bessie","lastname":"Orr","age":31,"gender":"F","address":"239 Hinsdale Street","employer":"Skyplex","email":"bessieorr@skyplex.com","city":"Graball","state":"FL"} -{"account_number":24,"balance":44182,"firstname":"Wood","lastname":"Dale","age":39,"gender":"M","address":"582 Gelston Avenue","employer":"Besto","email":"wooddale@besto.com","city":"Juntura","state":"MI"} -{"account_number":29,"balance":27323,"firstname":"Leah","lastname":"Santiago","age":33,"gender":"M","address":"193 Schenck Avenue","employer":"Isologix","email":"leahsantiago@isologix.com","city":"Gerton","state":"ND"} -{"account_number":31,"balance":30443,"firstname":"Kristen","lastname":"Santana","age":22,"gender":"F","address":"130 Middagh Street","employer":"Dogspa","email":"kristensantana@dogspa.com","city":"Vale","state":"MA"} -{"account_number":36,"balance":15902,"firstname":"Alexandra","lastname":"Nguyen","age":39,"gender":"F","address":"389 Elizabeth Place","employer":"Bittor","email":"alexandranguyen@bittor.com","city":"Hemlock","state":"KY"} -{"account_number":43,"balance":33474,"firstname":"Ryan","lastname":"Howe","age":25,"gender":"M","address":"660 Huntington Street","employer":"Microluxe","email":"ryanhowe@microluxe.com","city":"Clara","state":"CT"} -{"account_number":48,"balance":40608,"firstname":"Peck","lastname":"Downs","age":39,"gender":"F","address":"594 Dwight Street","employer":"Ramjob","email":"peckdowns@ramjob.com","city":"Coloma","state":"WA"} -{"account_number":50,"balance":43695,"firstname":"Sheena","lastname":"Kirkland","age":33,"gender":"M","address":"598 Bank Street","employer":"Zerbina","email":"sheenakirkland@zerbina.com","city":"Walland","state":"IN"} -{"account_number":55,"balance":22020,"firstname":"Shelia","lastname":"Puckett","age":33,"gender":"M","address":"265 Royce Place","employer":"Izzby","email":"sheliapuckett@izzby.com","city":"Slovan","state":"HI"} -{"account_number":62,"balance":43065,"firstname":"Lester","lastname":"Stanton","age":37,"gender":"M","address":"969 Doughty Street","employer":"Geekko","email":"lesterstanton@geekko.com","city":"Itmann","state":"DC"} -{"account_number":67,"balance":39430,"firstname":"Isabelle","lastname":"Spence","age":39,"gender":"M","address":"718 Troy Avenue","employer":"Geeketron","email":"isabellespence@geeketron.com","city":"Camptown","state":"WA"} -{"account_number":74,"balance":47167,"firstname":"Lauri","lastname":"Saunders","age":38,"gender":"F","address":"768 Lynch Street","employer":"Securia","email":"laurisaunders@securia.com","city":"Caroline","state":"TN"} -{"account_number":79,"balance":28185,"firstname":"Booker","lastname":"Lowery","age":29,"gender":"M","address":"817 Campus Road","employer":"Sensate","email":"bookerlowery@sensate.com","city":"Carlos","state":"MT"} -{"account_number":81,"balance":46568,"firstname":"Dennis","lastname":"Gilbert","age":40,"gender":"M","address":"619 Minna Street","employer":"Melbacor","email":"dennisgilbert@melbacor.com","city":"Kersey","state":"ND"} -{"account_number":86,"balance":15428,"firstname":"Walton","lastname":"Butler","age":36,"gender":"M","address":"999 Schenck Street","employer":"Unisure","email":"waltonbutler@unisure.com","city":"Bentonville","state":"IL"} -{"account_number":93,"balance":17728,"firstname":"Jeri","lastname":"Booth","age":31,"gender":"M","address":"322 Roosevelt Court","employer":"Geekology","email":"jeribooth@geekology.com","city":"Leming","state":"ND"} -{"account_number":98,"balance":15085,"firstname":"Cora","lastname":"Barrett","age":24,"gender":"F","address":"555 Neptune Court","employer":"Kiosk","email":"corabarrett@kiosk.com","city":"Independence","state":"MN"} -{"account_number":101,"balance":43400,"firstname":"Cecelia","lastname":"Grimes","age":31,"gender":"M","address":"972 Lincoln Place","employer":"Ecosys","email":"ceceliagrimes@ecosys.com","city":"Manchester","state":"AR"} -{"account_number":106,"balance":8212,"firstname":"Josefina","lastname":"Wagner","age":36,"gender":"M","address":"418 Estate Road","employer":"Kyaguru","email":"josefinawagner@kyaguru.com","city":"Darbydale","state":"FL"} -{"account_number":113,"balance":41652,"firstname":"Burt","lastname":"Moses","age":27,"gender":"M","address":"633 Berry Street","employer":"Uni","email":"burtmoses@uni.com","city":"Russellville","state":"CT"} -{"account_number":118,"balance":2223,"firstname":"Ballard","lastname":"Vasquez","age":33,"gender":"F","address":"101 Bush Street","employer":"Intergeek","email":"ballardvasquez@intergeek.com","city":"Century","state":"MN"} -{"account_number":120,"balance":38565,"firstname":"Browning","lastname":"Rodriquez","age":33,"gender":"M","address":"910 Moore Street","employer":"Opportech","email":"browningrodriquez@opportech.com","city":"Cutter","state":"ND"} -{"account_number":125,"balance":5396,"firstname":"Tanisha","lastname":"Dixon","age":30,"gender":"M","address":"482 Hancock Street","employer":"Junipoor","email":"tanishadixon@junipoor.com","city":"Wauhillau","state":"IA"} -{"account_number":132,"balance":37707,"firstname":"Horton","lastname":"Romero","age":35,"gender":"M","address":"427 Rutherford Place","employer":"Affluex","email":"hortonromero@affluex.com","city":"Hall","state":"AK"} -{"account_number":137,"balance":3596,"firstname":"Frost","lastname":"Freeman","age":29,"gender":"F","address":"191 Dennett Place","employer":"Beadzza","email":"frostfreeman@beadzza.com","city":"Sabillasville","state":"HI"} -{"account_number":144,"balance":43257,"firstname":"Evans","lastname":"Dyer","age":30,"gender":"F","address":"912 Post Court","employer":"Magmina","email":"evansdyer@magmina.com","city":"Gordon","state":"HI"} -{"account_number":149,"balance":22994,"firstname":"Megan","lastname":"Gonzales","age":21,"gender":"M","address":"836 Tampa Court","employer":"Andershun","email":"megangonzales@andershun.com","city":"Rockhill","state":"AL"} -{"account_number":151,"balance":34473,"firstname":"Kent","lastname":"Joyner","age":20,"gender":"F","address":"799 Truxton Street","employer":"Kozgene","email":"kentjoyner@kozgene.com","city":"Allamuchy","state":"DC"} -{"account_number":156,"balance":40185,"firstname":"Sloan","lastname":"Pennington","age":24,"gender":"F","address":"573 Opal Court","employer":"Hopeli","email":"sloanpennington@hopeli.com","city":"Evergreen","state":"CT"} -{"account_number":163,"balance":43075,"firstname":"Wilda","lastname":"Norman","age":33,"gender":"F","address":"173 Beadel Street","employer":"Kog","email":"wildanorman@kog.com","city":"Bodega","state":"ME"} -{"account_number":168,"balance":49568,"firstname":"Carissa","lastname":"Simon","age":20,"gender":"M","address":"975 Flatbush Avenue","employer":"Zillacom","email":"carissasimon@zillacom.com","city":"Neibert","state":"IL"} -{"account_number":170,"balance":6025,"firstname":"Mann","lastname":"Madden","age":36,"gender":"F","address":"161 Radde Place","employer":"Farmex","email":"mannmadden@farmex.com","city":"Thermal","state":"LA"} -{"account_number":175,"balance":16213,"firstname":"Montoya","lastname":"Donaldson","age":28,"gender":"F","address":"481 Morton Street","employer":"Envire","email":"montoyadonaldson@envire.com","city":"Delco","state":"MA"} -{"account_number":182,"balance":7803,"firstname":"Manuela","lastname":"Dillon","age":21,"gender":"M","address":"742 Garnet Street","employer":"Moreganic","email":"manueladillon@moreganic.com","city":"Ilchester","state":"TX"} -{"account_number":187,"balance":26581,"firstname":"Autumn","lastname":"Hodges","age":35,"gender":"M","address":"757 Granite Street","employer":"Ezentia","email":"autumnhodges@ezentia.com","city":"Martinsville","state":"KY"} -{"account_number":194,"balance":16311,"firstname":"Beck","lastname":"Rosario","age":39,"gender":"M","address":"721 Cambridge Place","employer":"Zoid","email":"beckrosario@zoid.com","city":"Efland","state":"ID"} -{"account_number":199,"balance":18086,"firstname":"Branch","lastname":"Love","age":26,"gender":"M","address":"458 Commercial Street","employer":"Frolix","email":"branchlove@frolix.com","city":"Caspar","state":"NC"} -{"account_number":202,"balance":26466,"firstname":"Medina","lastname":"Brown","age":31,"gender":"F","address":"519 Sunnyside Court","employer":"Bleendot","email":"medinabrown@bleendot.com","city":"Winfred","state":"MI"} -{"account_number":207,"balance":45535,"firstname":"Evelyn","lastname":"Lara","age":35,"gender":"F","address":"636 Chestnut Street","employer":"Ultrasure","email":"evelynlara@ultrasure.com","city":"Logan","state":"MI"} -{"account_number":214,"balance":24418,"firstname":"Luann","lastname":"Faulkner","age":37,"gender":"F","address":"697 Hazel Court","employer":"Zolar","email":"luannfaulkner@zolar.com","city":"Ticonderoga","state":"TX"} -{"account_number":219,"balance":17127,"firstname":"Edwards","lastname":"Hurley","age":25,"gender":"M","address":"834 Stockholm Street","employer":"Austech","email":"edwardshurley@austech.com","city":"Bayview","state":"NV"} -{"account_number":221,"balance":15803,"firstname":"Benjamin","lastname":"Barrera","age":34,"gender":"M","address":"568 Main Street","employer":"Zaphire","email":"benjaminbarrera@zaphire.com","city":"Germanton","state":"WY"} -{"account_number":226,"balance":37720,"firstname":"Wilkins","lastname":"Brady","age":40,"gender":"F","address":"486 Baltic Street","employer":"Dogtown","email":"wilkinsbrady@dogtown.com","city":"Condon","state":"MT"} -{"account_number":233,"balance":23020,"firstname":"Washington","lastname":"Walsh","age":27,"gender":"M","address":"366 Church Avenue","employer":"Candecor","email":"washingtonwalsh@candecor.com","city":"Westphalia","state":"MA"} -{"account_number":238,"balance":21287,"firstname":"Constance","lastname":"Wong","age":28,"gender":"M","address":"496 Brown Street","employer":"Grainspot","email":"constancewong@grainspot.com","city":"Cecilia","state":"IN"} -{"account_number":240,"balance":49741,"firstname":"Oconnor","lastname":"Clay","age":35,"gender":"F","address":"659 Highland Boulevard","employer":"Franscene","email":"oconnorclay@franscene.com","city":"Kilbourne","state":"NH"} -{"account_number":245,"balance":22026,"firstname":"Fran","lastname":"Bolton","age":28,"gender":"F","address":"147 Jerome Street","employer":"Solaren","email":"franbolton@solaren.com","city":"Nash","state":"RI"} -{"account_number":252,"balance":18831,"firstname":"Elvia","lastname":"Poole","age":22,"gender":"F","address":"836 Delevan Street","employer":"Velity","email":"elviapoole@velity.com","city":"Groveville","state":"MI"} -{"account_number":257,"balance":5318,"firstname":"Olive","lastname":"Oneil","age":35,"gender":"F","address":"457 Decatur Street","employer":"Helixo","email":"oliveoneil@helixo.com","city":"Chicopee","state":"MI"} -{"account_number":264,"balance":22084,"firstname":"Samantha","lastname":"Ferrell","age":35,"gender":"F","address":"488 Fulton Street","employer":"Flum","email":"samanthaferrell@flum.com","city":"Brandywine","state":"MT"} -{"account_number":269,"balance":43317,"firstname":"Crosby","lastname":"Figueroa","age":34,"gender":"M","address":"910 Aurelia Court","employer":"Pyramia","email":"crosbyfigueroa@pyramia.com","city":"Leyner","state":"OH"} -{"account_number":271,"balance":11864,"firstname":"Holt","lastname":"Walter","age":30,"gender":"F","address":"645 Poplar Avenue","employer":"Grupoli","email":"holtwalter@grupoli.com","city":"Mansfield","state":"OR"} -{"account_number":276,"balance":11606,"firstname":"Pittman","lastname":"Mathis","age":23,"gender":"F","address":"567 Charles Place","employer":"Zuvy","email":"pittmanmathis@zuvy.com","city":"Roeville","state":"KY"} -{"account_number":283,"balance":24070,"firstname":"Fuentes","lastname":"Foley","age":30,"gender":"M","address":"729 Walker Court","employer":"Knowlysis","email":"fuentesfoley@knowlysis.com","city":"Tryon","state":"TN"} -{"account_number":288,"balance":27243,"firstname":"Wong","lastname":"Stone","age":39,"gender":"F","address":"440 Willoughby Street","employer":"Zentix","email":"wongstone@zentix.com","city":"Wheatfields","state":"DC"} -{"account_number":290,"balance":26103,"firstname":"Neva","lastname":"Burgess","age":37,"gender":"F","address":"985 Wyona Street","employer":"Slofast","email":"nevaburgess@slofast.com","city":"Cawood","state":"DC"} -{"account_number":295,"balance":37358,"firstname":"Howe","lastname":"Nash","age":20,"gender":"M","address":"833 Union Avenue","employer":"Aquacine","email":"howenash@aquacine.com","city":"Indio","state":"MN"} -{"account_number":303,"balance":21976,"firstname":"Huffman","lastname":"Green","age":24,"gender":"F","address":"455 Colby Court","employer":"Comtest","email":"huffmangreen@comtest.com","city":"Weeksville","state":"UT"} -{"account_number":308,"balance":33989,"firstname":"Glass","lastname":"Schroeder","age":25,"gender":"F","address":"670 Veterans Avenue","employer":"Realmo","email":"glassschroeder@realmo.com","city":"Gratton","state":"NY"} -{"account_number":310,"balance":23049,"firstname":"Shannon","lastname":"Morton","age":39,"gender":"F","address":"412 Pleasant Place","employer":"Ovation","email":"shannonmorton@ovation.com","city":"Edgar","state":"AZ"} -{"account_number":315,"balance":1314,"firstname":"Clare","lastname":"Morrow","age":33,"gender":"F","address":"728 Madeline Court","employer":"Gaptec","email":"claremorrow@gaptec.com","city":"Mapletown","state":"PA"} -{"account_number":322,"balance":6303,"firstname":"Gilliam","lastname":"Horne","age":27,"gender":"M","address":"414 Florence Avenue","employer":"Shepard","email":"gilliamhorne@shepard.com","city":"Winesburg","state":"WY"} -{"account_number":327,"balance":29294,"firstname":"Nell","lastname":"Contreras","age":27,"gender":"M","address":"694 Gold Street","employer":"Momentia","email":"nellcontreras@momentia.com","city":"Cumminsville","state":"AL"} -{"account_number":334,"balance":9178,"firstname":"Cross","lastname":"Floyd","age":21,"gender":"F","address":"815 Herkimer Court","employer":"Maroptic","email":"crossfloyd@maroptic.com","city":"Kraemer","state":"AK"} -{"account_number":339,"balance":3992,"firstname":"Franco","lastname":"Welch","age":38,"gender":"F","address":"776 Brightwater Court","employer":"Earthplex","email":"francowelch@earthplex.com","city":"Naomi","state":"ME"} -{"account_number":341,"balance":44367,"firstname":"Alberta","lastname":"Bradford","age":30,"gender":"F","address":"670 Grant Avenue","employer":"Bugsall","email":"albertabradford@bugsall.com","city":"Romeville","state":"MT"} -{"account_number":346,"balance":26594,"firstname":"Shelby","lastname":"Sanchez","age":36,"gender":"F","address":"257 Fillmore Avenue","employer":"Geekus","email":"shelbysanchez@geekus.com","city":"Seymour","state":"CO"} -{"account_number":353,"balance":45182,"firstname":"Rivera","lastname":"Sherman","age":37,"gender":"M","address":"603 Garden Place","employer":"Bovis","email":"riverasherman@bovis.com","city":"Otranto","state":"CA"} -{"account_number":358,"balance":44043,"firstname":"Hale","lastname":"Baldwin","age":40,"gender":"F","address":"845 Menahan Street","employer":"Kidgrease","email":"halebaldwin@kidgrease.com","city":"Day","state":"AK"} -{"account_number":360,"balance":26651,"firstname":"Ward","lastname":"Hicks","age":34,"gender":"F","address":"592 Brighton Court","employer":"Biotica","email":"wardhicks@biotica.com","city":"Kanauga","state":"VT"} -{"account_number":365,"balance":3176,"firstname":"Sanders","lastname":"Holder","age":31,"gender":"F","address":"453 Cypress Court","employer":"Geekola","email":"sandersholder@geekola.com","city":"Staples","state":"TN"} -{"account_number":372,"balance":28566,"firstname":"Alba","lastname":"Forbes","age":24,"gender":"M","address":"814 Meserole Avenue","employer":"Isostream","email":"albaforbes@isostream.com","city":"Clarence","state":"OR"} -{"account_number":377,"balance":5374,"firstname":"Margo","lastname":"Gay","age":34,"gender":"F","address":"613 Chase Court","employer":"Rotodyne","email":"margogay@rotodyne.com","city":"Waumandee","state":"KS"} -{"account_number":384,"balance":48758,"firstname":"Sallie","lastname":"Houston","age":31,"gender":"F","address":"836 Polar Street","employer":"Squish","email":"salliehouston@squish.com","city":"Morningside","state":"NC"} -{"account_number":389,"balance":8839,"firstname":"York","lastname":"Cummings","age":27,"gender":"M","address":"778 Centre Street","employer":"Insurity","email":"yorkcummings@insurity.com","city":"Freeburn","state":"RI"} -{"account_number":391,"balance":14733,"firstname":"Holman","lastname":"Jordan","age":30,"gender":"M","address":"391 Forrest Street","employer":"Maineland","email":"holmanjordan@maineland.com","city":"Cade","state":"CT"} -{"account_number":396,"balance":14613,"firstname":"Marsha","lastname":"Elliott","age":38,"gender":"F","address":"297 Liberty Avenue","employer":"Orbiflex","email":"marshaelliott@orbiflex.com","city":"Windsor","state":"TX"} -{"account_number":404,"balance":34978,"firstname":"Massey","lastname":"Becker","age":26,"gender":"F","address":"930 Pitkin Avenue","employer":"Genekom","email":"masseybecker@genekom.com","city":"Blairstown","state":"OR"} -{"account_number":409,"balance":36960,"firstname":"Maura","lastname":"Glenn","age":31,"gender":"M","address":"183 Poly Place","employer":"Viagreat","email":"mauraglenn@viagreat.com","city":"Foscoe","state":"DE"} -{"account_number":411,"balance":1172,"firstname":"Guzman","lastname":"Whitfield","age":22,"gender":"M","address":"181 Perry Terrace","employer":"Springbee","email":"guzmanwhitfield@springbee.com","city":"Balm","state":"IN"} -{"account_number":416,"balance":27169,"firstname":"Hunt","lastname":"Schwartz","age":28,"gender":"F","address":"461 Havens Place","employer":"Danja","email":"huntschwartz@danja.com","city":"Grenelefe","state":"NV"} -{"account_number":423,"balance":38852,"firstname":"Hines","lastname":"Underwood","age":21,"gender":"F","address":"284 Louise Terrace","employer":"Namegen","email":"hinesunderwood@namegen.com","city":"Downsville","state":"CO"} -{"account_number":428,"balance":13925,"firstname":"Stephens","lastname":"Cain","age":20,"gender":"F","address":"189 Summit Street","employer":"Rocklogic","email":"stephenscain@rocklogic.com","city":"Bourg","state":"HI"} -{"account_number":430,"balance":15251,"firstname":"Alejandra","lastname":"Chavez","age":34,"gender":"M","address":"651 Butler Place","employer":"Gology","email":"alejandrachavez@gology.com","city":"Allensworth","state":"VT"} -{"account_number":435,"balance":14654,"firstname":"Sue","lastname":"Lopez","age":22,"gender":"F","address":"632 Stone Avenue","employer":"Emergent","email":"suelopez@emergent.com","city":"Waterford","state":"TN"} -{"account_number":442,"balance":36211,"firstname":"Lawanda","lastname":"Leon","age":27,"gender":"F","address":"126 Canal Avenue","employer":"Xixan","email":"lawandaleon@xixan.com","city":"Berwind","state":"TN"} -{"account_number":447,"balance":11402,"firstname":"Lucia","lastname":"Livingston","age":35,"gender":"M","address":"773 Lake Avenue","employer":"Soprano","email":"lucialivingston@soprano.com","city":"Edgewater","state":"TN"} -{"account_number":454,"balance":31687,"firstname":"Alicia","lastname":"Rollins","age":22,"gender":"F","address":"483 Verona Place","employer":"Boilcat","email":"aliciarollins@boilcat.com","city":"Lutsen","state":"MD"} -{"account_number":459,"balance":18869,"firstname":"Pamela","lastname":"Henry","age":20,"gender":"F","address":"361 Locust Avenue","employer":"Imageflow","email":"pamelahenry@imageflow.com","city":"Greenfields","state":"OH"} -{"account_number":461,"balance":38807,"firstname":"Mcbride","lastname":"Padilla","age":34,"gender":"F","address":"550 Borinquen Pl","employer":"Zepitope","email":"mcbridepadilla@zepitope.com","city":"Emory","state":"AZ"} -{"account_number":466,"balance":25109,"firstname":"Marcie","lastname":"Mcmillan","age":30,"gender":"F","address":"947 Gain Court","employer":"Entroflex","email":"marciemcmillan@entroflex.com","city":"Ronco","state":"ND"} -{"account_number":473,"balance":5391,"firstname":"Susan","lastname":"Luna","age":25,"gender":"F","address":"521 Bogart Street","employer":"Zaya","email":"susanluna@zaya.com","city":"Grazierville","state":"MI"} -{"account_number":478,"balance":28044,"firstname":"Dana","lastname":"Decker","age":35,"gender":"M","address":"627 Dobbin Street","employer":"Acrodance","email":"danadecker@acrodance.com","city":"Sharon","state":"MN"} -{"account_number":480,"balance":40807,"firstname":"Anastasia","lastname":"Parker","age":24,"gender":"M","address":"650 Folsom Place","employer":"Zilladyne","email":"anastasiaparker@zilladyne.com","city":"Oberlin","state":"WY"} -{"account_number":485,"balance":44235,"firstname":"Albert","lastname":"Roberts","age":40,"gender":"M","address":"385 Harman Street","employer":"Stralum","email":"albertroberts@stralum.com","city":"Watrous","state":"NM"} -{"account_number":492,"balance":31055,"firstname":"Burnett","lastname":"Briggs","age":35,"gender":"M","address":"987 Cass Place","employer":"Pharmex","email":"burnettbriggs@pharmex.com","city":"Cornfields","state":"TX"} -{"account_number":497,"balance":13493,"firstname":"Doyle","lastname":"Jenkins","age":30,"gender":"M","address":"205 Nevins Street","employer":"Unia","email":"doylejenkins@unia.com","city":"Nicut","state":"DC"} -{"account_number":500,"balance":39143,"firstname":"Pope","lastname":"Keith","age":28,"gender":"F","address":"537 Fane Court","employer":"Zboo","email":"popekeith@zboo.com","city":"Courtland","state":"AL"} -{"account_number":505,"balance":45493,"firstname":"Shelley","lastname":"Webb","age":29,"gender":"M","address":"873 Crawford Avenue","employer":"Quadeebo","email":"shelleywebb@quadeebo.com","city":"Topanga","state":"IL"} -{"account_number":512,"balance":47432,"firstname":"Alisha","lastname":"Morales","age":29,"gender":"M","address":"623 Batchelder Street","employer":"Terragen","email":"alishamorales@terragen.com","city":"Gilmore","state":"VA"} -{"account_number":517,"balance":3022,"firstname":"Allyson","lastname":"Walls","age":38,"gender":"F","address":"334 Coffey Street","employer":"Gorganic","email":"allysonwalls@gorganic.com","city":"Dahlen","state":"GA"} -{"account_number":524,"balance":49334,"firstname":"Salas","lastname":"Farley","age":30,"gender":"F","address":"499 Trucklemans Lane","employer":"Xumonk","email":"salasfarley@xumonk.com","city":"Noxen","state":"AL"} -{"account_number":529,"balance":21788,"firstname":"Deann","lastname":"Fisher","age":23,"gender":"F","address":"511 Buffalo Avenue","employer":"Twiist","email":"deannfisher@twiist.com","city":"Templeton","state":"WA"} -{"account_number":531,"balance":39770,"firstname":"Janet","lastname":"Pena","age":38,"gender":"M","address":"645 Livonia Avenue","employer":"Corecom","email":"janetpena@corecom.com","city":"Garberville","state":"OK"} -{"account_number":536,"balance":6255,"firstname":"Emma","lastname":"Adkins","age":33,"gender":"F","address":"971 Calder Place","employer":"Ontagene","email":"emmaadkins@ontagene.com","city":"Ruckersville","state":"GA"} -{"account_number":543,"balance":48022,"firstname":"Marina","lastname":"Rasmussen","age":31,"gender":"M","address":"446 Love Lane","employer":"Crustatia","email":"marinarasmussen@crustatia.com","city":"Statenville","state":"MD"} -{"account_number":548,"balance":36930,"firstname":"Sandra","lastname":"Andrews","age":37,"gender":"M","address":"973 Prospect Street","employer":"Datagene","email":"sandraandrews@datagene.com","city":"Inkerman","state":"MO"} -{"account_number":550,"balance":32238,"firstname":"Walsh","lastname":"Goodwin","age":22,"gender":"M","address":"953 Canda Avenue","employer":"Proflex","email":"walshgoodwin@proflex.com","city":"Ypsilanti","state":"MT"} -{"account_number":555,"balance":10750,"firstname":"Fannie","lastname":"Slater","age":31,"gender":"M","address":"457 Tech Place","employer":"Kineticut","email":"fannieslater@kineticut.com","city":"Basye","state":"MO"} -{"account_number":562,"balance":10737,"firstname":"Sarah","lastname":"Strong","age":39,"gender":"F","address":"177 Pioneer Street","employer":"Megall","email":"sarahstrong@megall.com","city":"Ladera","state":"WY"} -{"account_number":567,"balance":6507,"firstname":"Diana","lastname":"Dominguez","age":40,"gender":"M","address":"419 Albany Avenue","employer":"Ohmnet","email":"dianadominguez@ohmnet.com","city":"Wildwood","state":"TX"} -{"account_number":574,"balance":32954,"firstname":"Andrea","lastname":"Mosley","age":24,"gender":"M","address":"368 Throop Avenue","employer":"Musix","email":"andreamosley@musix.com","city":"Blende","state":"DC"} -{"account_number":579,"balance":12044,"firstname":"Banks","lastname":"Sawyer","age":36,"gender":"M","address":"652 Doone Court","employer":"Rooforia","email":"bankssawyer@rooforia.com","city":"Foxworth","state":"ND"} -{"account_number":581,"balance":16525,"firstname":"Fuller","lastname":"Mcintyre","age":32,"gender":"M","address":"169 Bergen Place","employer":"Applideck","email":"fullermcintyre@applideck.com","city":"Kenvil","state":"NY"} -{"account_number":586,"balance":13644,"firstname":"Love","lastname":"Velasquez","age":26,"gender":"F","address":"290 Girard Street","employer":"Zomboid","email":"lovevelasquez@zomboid.com","city":"Villarreal","state":"SD"} -{"account_number":593,"balance":41230,"firstname":"Muriel","lastname":"Vazquez","age":37,"gender":"M","address":"395 Montgomery Street","employer":"Sustenza","email":"murielvazquez@sustenza.com","city":"Strykersville","state":"OK"} -{"account_number":598,"balance":33251,"firstname":"Morgan","lastname":"Coleman","age":33,"gender":"M","address":"324 McClancy Place","employer":"Aclima","email":"morgancoleman@aclima.com","city":"Bowden","state":"WA"} -{"account_number":601,"balance":20796,"firstname":"Vickie","lastname":"Valentine","age":34,"gender":"F","address":"432 Bassett Avenue","employer":"Comvene","email":"vickievalentine@comvene.com","city":"Teasdale","state":"UT"} -{"account_number":606,"balance":28770,"firstname":"Michael","lastname":"Bray","age":31,"gender":"M","address":"935 Lake Place","employer":"Telepark","email":"michaelbray@telepark.com","city":"Lemoyne","state":"CT"} -{"account_number":613,"balance":39340,"firstname":"Eddie","lastname":"Mccarty","age":34,"gender":"F","address":"971 Richards Street","employer":"Bisba","email":"eddiemccarty@bisba.com","city":"Fruitdale","state":"NY"} -{"account_number":618,"balance":8976,"firstname":"Cheri","lastname":"Ford","age":30,"gender":"F","address":"803 Ridgewood Avenue","employer":"Zorromop","email":"cheriford@zorromop.com","city":"Gambrills","state":"VT"} -{"account_number":620,"balance":7224,"firstname":"Coleen","lastname":"Bartlett","age":38,"gender":"M","address":"761 Carroll Street","employer":"Idealis","email":"coleenbartlett@idealis.com","city":"Mathews","state":"DE"} -{"account_number":625,"balance":46010,"firstname":"Cynthia","lastname":"Johnston","age":23,"gender":"M","address":"142 Box Street","employer":"Zentry","email":"cynthiajohnston@zentry.com","city":"Makena","state":"MA"} -{"account_number":632,"balance":40470,"firstname":"Kay","lastname":"Warren","age":20,"gender":"F","address":"422 Alabama Avenue","employer":"Realysis","email":"kaywarren@realysis.com","city":"Homestead","state":"HI"} -{"account_number":637,"balance":3169,"firstname":"Kathy","lastname":"Carter","age":27,"gender":"F","address":"410 Jamison Lane","employer":"Limage","email":"kathycarter@limage.com","city":"Ernstville","state":"WA"} -{"account_number":644,"balance":44021,"firstname":"Etta","lastname":"Miller","age":21,"gender":"F","address":"376 Lawton Street","employer":"Bluegrain","email":"ettamiller@bluegrain.com","city":"Baker","state":"MD"} -{"account_number":649,"balance":20275,"firstname":"Jeanine","lastname":"Malone","age":26,"gender":"F","address":"114 Dodworth Street","employer":"Nixelt","email":"jeaninemalone@nixelt.com","city":"Keyport","state":"AK"} -{"account_number":651,"balance":18360,"firstname":"Young","lastname":"Reeves","age":34,"gender":"M","address":"581 Plaza Street","employer":"Krog","email":"youngreeves@krog.com","city":"Sussex","state":"WY"} -{"account_number":656,"balance":21632,"firstname":"Olson","lastname":"Hunt","age":36,"gender":"M","address":"342 Jaffray Street","employer":"Volax","email":"olsonhunt@volax.com","city":"Bangor","state":"WA"} -{"account_number":663,"balance":2456,"firstname":"Rollins","lastname":"Richards","age":37,"gender":"M","address":"129 Sullivan Place","employer":"Geostele","email":"rollinsrichards@geostele.com","city":"Morgandale","state":"FL"} -{"account_number":668,"balance":45069,"firstname":"Potter","lastname":"Michael","age":27,"gender":"M","address":"803 Glenmore Avenue","employer":"Ontality","email":"pottermichael@ontality.com","city":"Newkirk","state":"KS"} -{"account_number":670,"balance":10178,"firstname":"Ollie","lastname":"Riley","age":22,"gender":"M","address":"252 Jackson Place","employer":"Adornica","email":"ollieriley@adornica.com","city":"Brethren","state":"WI"} -{"account_number":675,"balance":36102,"firstname":"Fisher","lastname":"Shepard","age":27,"gender":"F","address":"859 Varick Street","employer":"Qot","email":"fishershepard@qot.com","city":"Diaperville","state":"MD"} -{"account_number":682,"balance":14168,"firstname":"Anne","lastname":"Hale","age":22,"gender":"F","address":"708 Anthony Street","employer":"Cytrek","email":"annehale@cytrek.com","city":"Beechmont","state":"WV"} -{"account_number":687,"balance":48630,"firstname":"Caroline","lastname":"Cox","age":31,"gender":"M","address":"626 Hillel Place","employer":"Opticon","email":"carolinecox@opticon.com","city":"Loma","state":"ND"} -{"account_number":694,"balance":33125,"firstname":"Craig","lastname":"Palmer","age":31,"gender":"F","address":"273 Montrose Avenue","employer":"Comvey","email":"craigpalmer@comvey.com","city":"Cleary","state":"OK"} -{"account_number":699,"balance":4156,"firstname":"Gallagher","lastname":"Marshall","age":37,"gender":"F","address":"648 Clifford Place","employer":"Exiand","email":"gallaghermarshall@exiand.com","city":"Belfair","state":"KY"} -{"account_number":702,"balance":46490,"firstname":"Meadows","lastname":"Delgado","age":26,"gender":"M","address":"612 Jardine Place","employer":"Daisu","email":"meadowsdelgado@daisu.com","city":"Venice","state":"AR"} -{"account_number":707,"balance":30325,"firstname":"Sonya","lastname":"Trevino","age":30,"gender":"F","address":"181 Irving Place","employer":"Atgen","email":"sonyatrevino@atgen.com","city":"Enetai","state":"TN"} -{"account_number":714,"balance":16602,"firstname":"Socorro","lastname":"Murray","age":34,"gender":"F","address":"810 Manhattan Court","employer":"Isoswitch","email":"socorromurray@isoswitch.com","city":"Jugtown","state":"AZ"} -{"account_number":719,"balance":33107,"firstname":"Leanna","lastname":"Reed","age":25,"gender":"F","address":"528 Krier Place","employer":"Rodeology","email":"leannareed@rodeology.com","city":"Carrizo","state":"WI"} -{"account_number":721,"balance":32958,"firstname":"Mara","lastname":"Dickson","age":26,"gender":"M","address":"810 Harrison Avenue","employer":"Comtours","email":"maradickson@comtours.com","city":"Thynedale","state":"DE"} -{"account_number":726,"balance":44737,"firstname":"Rosemary","lastname":"Salazar","age":21,"gender":"M","address":"290 Croton Loop","employer":"Rockabye","email":"rosemarysalazar@rockabye.com","city":"Helen","state":"IA"} -{"account_number":733,"balance":15722,"firstname":"Lakeisha","lastname":"Mccarthy","age":37,"gender":"M","address":"782 Turnbull Avenue","employer":"Exosis","email":"lakeishamccarthy@exosis.com","city":"Caberfae","state":"NM"} -{"account_number":738,"balance":44936,"firstname":"Rosalind","lastname":"Hunter","age":32,"gender":"M","address":"644 Eaton Court","employer":"Zolarity","email":"rosalindhunter@zolarity.com","city":"Cataract","state":"SD"} -{"account_number":740,"balance":6143,"firstname":"Chambers","lastname":"Hahn","age":22,"gender":"M","address":"937 Windsor Place","employer":"Medalert","email":"chambershahn@medalert.com","city":"Dorneyville","state":"DC"} -{"account_number":745,"balance":4572,"firstname":"Jacobs","lastname":"Sweeney","age":32,"gender":"M","address":"189 Lott Place","employer":"Comtent","email":"jacobssweeney@comtent.com","city":"Advance","state":"NJ"} -{"account_number":752,"balance":14039,"firstname":"Jerry","lastname":"Rush","age":31,"gender":"M","address":"632 Dank Court","employer":"Ebidco","email":"jerryrush@ebidco.com","city":"Geyserville","state":"AR"} -{"account_number":757,"balance":34628,"firstname":"Mccullough","lastname":"Moore","age":30,"gender":"F","address":"304 Hastings Street","employer":"Nikuda","email":"mcculloughmoore@nikuda.com","city":"Charco","state":"DC"} -{"account_number":764,"balance":3728,"firstname":"Noemi","lastname":"Gill","age":30,"gender":"M","address":"427 Chester Street","employer":"Avit","email":"noemigill@avit.com","city":"Chesterfield","state":"AL"} -{"account_number":769,"balance":15362,"firstname":"Francis","lastname":"Beck","age":28,"gender":"M","address":"454 Livingston Street","employer":"Furnafix","email":"francisbeck@furnafix.com","city":"Dunnavant","state":"HI"} -{"account_number":771,"balance":32784,"firstname":"Jocelyn","lastname":"Boone","age":23,"gender":"M","address":"513 Division Avenue","employer":"Collaire","email":"jocelynboone@collaire.com","city":"Lisco","state":"VT"} -{"account_number":776,"balance":29177,"firstname":"Duke","lastname":"Atkinson","age":24,"gender":"M","address":"520 Doscher Street","employer":"Tripsch","email":"dukeatkinson@tripsch.com","city":"Lafferty","state":"NC"} -{"account_number":783,"balance":11911,"firstname":"Faith","lastname":"Cooper","age":25,"gender":"F","address":"539 Rapelye Street","employer":"Insuron","email":"faithcooper@insuron.com","city":"Jennings","state":"MN"} -{"account_number":788,"balance":12473,"firstname":"Marianne","lastname":"Aguilar","age":39,"gender":"F","address":"213 Holly Street","employer":"Marqet","email":"marianneaguilar@marqet.com","city":"Alfarata","state":"HI"} -{"account_number":790,"balance":29912,"firstname":"Ellis","lastname":"Sullivan","age":39,"gender":"F","address":"877 Coyle Street","employer":"Enersave","email":"ellissullivan@enersave.com","city":"Canby","state":"MS"} -{"account_number":795,"balance":31450,"firstname":"Bruce","lastname":"Avila","age":34,"gender":"M","address":"865 Newkirk Placez","employer":"Plasmosis","email":"bruceavila@plasmosis.com","city":"Ada","state":"ID"} -{"account_number":803,"balance":49567,"firstname":"Marissa","lastname":"Spears","age":25,"gender":"M","address":"963 Highland Avenue","employer":"Centregy","email":"marissaspears@centregy.com","city":"Bloomington","state":"MS"} -{"account_number":808,"balance":11251,"firstname":"Nola","lastname":"Quinn","age":20,"gender":"M","address":"863 Wythe Place","employer":"Iplax","email":"nolaquinn@iplax.com","city":"Cuylerville","state":"NH"} -{"account_number":810,"balance":10563,"firstname":"Alyssa","lastname":"Ortega","age":40,"gender":"M","address":"977 Clymer Street","employer":"Eventage","email":"alyssaortega@eventage.com","city":"Convent","state":"SC"} -{"account_number":815,"balance":19336,"firstname":"Guthrie","lastname":"Morse","age":30,"gender":"M","address":"685 Vandalia Avenue","employer":"Gronk","email":"guthriemorse@gronk.com","city":"Fowlerville","state":"OR"} -{"account_number":822,"balance":13024,"firstname":"Hicks","lastname":"Farrell","age":25,"gender":"M","address":"468 Middleton Street","employer":"Zolarex","email":"hicksfarrell@zolarex.com","city":"Columbus","state":"OR"} -{"account_number":827,"balance":37536,"firstname":"Naomi","lastname":"Ball","age":29,"gender":"F","address":"319 Stewart Street","employer":"Isotronic","email":"naomiball@isotronic.com","city":"Trona","state":"NM"} -{"account_number":834,"balance":38049,"firstname":"Sybil","lastname":"Carrillo","age":25,"gender":"M","address":"359 Baughman Place","employer":"Phuel","email":"sybilcarrillo@phuel.com","city":"Kohatk","state":"CT"} -{"account_number":839,"balance":38292,"firstname":"Langley","lastname":"Neal","age":39,"gender":"F","address":"565 Newton Street","employer":"Liquidoc","email":"langleyneal@liquidoc.com","city":"Osage","state":"AL"} -{"account_number":841,"balance":28291,"firstname":"Dalton","lastname":"Waters","age":21,"gender":"M","address":"859 Grand Street","employer":"Malathion","email":"daltonwaters@malathion.com","city":"Tonopah","state":"AZ"} -{"account_number":846,"balance":35099,"firstname":"Maureen","lastname":"Glass","age":22,"gender":"M","address":"140 Amherst Street","employer":"Stelaecor","email":"maureenglass@stelaecor.com","city":"Cucumber","state":"IL"} -{"account_number":853,"balance":38353,"firstname":"Travis","lastname":"Parks","age":40,"gender":"M","address":"930 Bay Avenue","employer":"Pyramax","email":"travisparks@pyramax.com","city":"Gadsden","state":"ND"} -{"account_number":858,"balance":23194,"firstname":"Small","lastname":"Hatfield","age":36,"gender":"M","address":"593 Tennis Court","employer":"Letpro","email":"smallhatfield@letpro.com","city":"Haena","state":"KS"} -{"account_number":860,"balance":23613,"firstname":"Clark","lastname":"Boyd","age":37,"gender":"M","address":"501 Rock Street","employer":"Deepends","email":"clarkboyd@deepends.com","city":"Whitewater","state":"MA"} -{"account_number":865,"balance":10574,"firstname":"Cook","lastname":"Kelley","age":28,"gender":"F","address":"865 Lincoln Terrace","employer":"Quizmo","email":"cookkelley@quizmo.com","city":"Kansas","state":"KY"} -{"account_number":872,"balance":26314,"firstname":"Jane","lastname":"Greer","age":36,"gender":"F","address":"717 Hewes Street","employer":"Newcube","email":"janegreer@newcube.com","city":"Delshire","state":"DE"} -{"account_number":877,"balance":42879,"firstname":"Tracey","lastname":"Ruiz","age":34,"gender":"F","address":"141 Tompkins Avenue","employer":"Waab","email":"traceyruiz@waab.com","city":"Zeba","state":"NM"} -{"account_number":884,"balance":29316,"firstname":"Reva","lastname":"Rosa","age":40,"gender":"M","address":"784 Greene Avenue","employer":"Urbanshee","email":"revarosa@urbanshee.com","city":"Bakersville","state":"MS"} -{"account_number":889,"balance":26464,"firstname":"Fischer","lastname":"Klein","age":38,"gender":"F","address":"948 Juliana Place","employer":"Comtext","email":"fischerklein@comtext.com","city":"Jackpot","state":"PA"} -{"account_number":891,"balance":34829,"firstname":"Jacobson","lastname":"Clemons","age":24,"gender":"F","address":"507 Wilson Street","employer":"Quilm","email":"jacobsonclemons@quilm.com","city":"Muir","state":"TX"} -{"account_number":896,"balance":31947,"firstname":"Buckley","lastname":"Peterson","age":26,"gender":"M","address":"217 Beayer Place","employer":"Earwax","email":"buckleypeterson@earwax.com","city":"Franklin","state":"DE"} -{"account_number":904,"balance":27707,"firstname":"Mendez","lastname":"Mcneil","age":26,"gender":"M","address":"431 Halsey Street","employer":"Macronaut","email":"mendezmcneil@macronaut.com","city":"Troy","state":"OK"} -{"account_number":909,"balance":18421,"firstname":"Stark","lastname":"Lewis","age":36,"gender":"M","address":"409 Tilden Avenue","employer":"Frosnex","email":"starklewis@frosnex.com","city":"Axis","state":"CA"} -{"account_number":911,"balance":42655,"firstname":"Annie","lastname":"Lyons","age":21,"gender":"M","address":"518 Woods Place","employer":"Enerforce","email":"annielyons@enerforce.com","city":"Stagecoach","state":"MA"} -{"account_number":916,"balance":47887,"firstname":"Jarvis","lastname":"Alexander","age":40,"gender":"M","address":"406 Bergen Avenue","employer":"Equitax","email":"jarvisalexander@equitax.com","city":"Haring","state":"KY"} -{"account_number":923,"balance":48466,"firstname":"Mueller","lastname":"Mckee","age":26,"gender":"M","address":"298 Ruby Street","employer":"Luxuria","email":"muellermckee@luxuria.com","city":"Coleville","state":"TN"} -{"account_number":928,"balance":19611,"firstname":"Hester","lastname":"Copeland","age":22,"gender":"F","address":"425 Cropsey Avenue","employer":"Dymi","email":"hestercopeland@dymi.com","city":"Wolcott","state":"NE"} -{"account_number":930,"balance":47257,"firstname":"Kinney","lastname":"Lawson","age":39,"gender":"M","address":"501 Raleigh Place","employer":"Neptide","email":"kinneylawson@neptide.com","city":"Deltaville","state":"MD"} -{"account_number":935,"balance":4959,"firstname":"Flowers","lastname":"Robles","age":30,"gender":"M","address":"201 Hull Street","employer":"Xelegyl","email":"flowersrobles@xelegyl.com","city":"Rehrersburg","state":"AL"} -{"account_number":942,"balance":21299,"firstname":"Hamilton","lastname":"Clayton","age":26,"gender":"M","address":"413 Debevoise Street","employer":"Architax","email":"hamiltonclayton@architax.com","city":"Terlingua","state":"NM"} -{"account_number":947,"balance":22039,"firstname":"Virgie","lastname":"Garza","age":30,"gender":"M","address":"903 Matthews Court","employer":"Plasmox","email":"virgiegarza@plasmox.com","city":"Somerset","state":"WY"} -{"account_number":954,"balance":49404,"firstname":"Jenna","lastname":"Martin","age":22,"gender":"M","address":"688 Hart Street","employer":"Zinca","email":"jennamartin@zinca.com","city":"Oasis","state":"MD"} -{"account_number":959,"balance":34743,"firstname":"Shaffer","lastname":"Cervantes","age":40,"gender":"M","address":"931 Varick Avenue","employer":"Oceanica","email":"shaffercervantes@oceanica.com","city":"Bowie","state":"AL"} -{"account_number":961,"balance":43219,"firstname":"Betsy","lastname":"Hyde","age":27,"gender":"F","address":"183 Junius Street","employer":"Tubalum","email":"betsyhyde@tubalum.com","city":"Driftwood","state":"TX"} -{"account_number":966,"balance":20619,"firstname":"Susanne","lastname":"Rodriguez","age":35,"gender":"F","address":"255 Knickerbocker Avenue","employer":"Comtrek","email":"susannerodriguez@comtrek.com","city":"Trinway","state":"TX"} -{"account_number":973,"balance":45756,"firstname":"Rice","lastname":"Farmer","age":31,"gender":"M","address":"476 Nassau Avenue","employer":"Photobin","email":"ricefarmer@photobin.com","city":"Suitland","state":"ME"} -{"account_number":978,"balance":21459,"firstname":"Melanie","lastname":"Rojas","age":33,"gender":"M","address":"991 Java Street","employer":"Kage","email":"melanierojas@kage.com","city":"Greenock","state":"VT"} -{"account_number":980,"balance":42436,"firstname":"Cash","lastname":"Collier","age":33,"gender":"F","address":"999 Sapphire Street","employer":"Ceprene","email":"cashcollier@ceprene.com","city":"Glidden","state":"AK"} -{"account_number":985,"balance":20083,"firstname":"Martin","lastname":"Gardner","age":28,"gender":"F","address":"644 Fairview Place","employer":"Golistic","email":"martingardner@golistic.com","city":"Connerton","state":"NJ"} -{"account_number":992,"balance":11413,"firstname":"Kristie","lastname":"Kennedy","age":33,"gender":"F","address":"750 Hudson Avenue","employer":"Ludak","email":"kristiekennedy@ludak.com","city":"Warsaw","state":"WY"} -{"account_number":997,"balance":25311,"firstname":"Combs","lastname":"Frederick","age":20,"gender":"M","address":"586 Lloyd Court","employer":"Pathways","email":"combsfrederick@pathways.com","city":"Williamson","state":"CA"} -{"account_number":3,"balance":44947,"firstname":"Levine","lastname":"Burks","age":26,"gender":"F","address":"328 Wilson Avenue","employer":"Amtap","email":"levineburks@amtap.com","city":"Cochranville","state":"HI"} -{"account_number":8,"balance":48868,"firstname":"Jan","lastname":"Burns","age":35,"gender":"M","address":"699 Visitation Place","employer":"Glasstep","email":"janburns@glasstep.com","city":"Wakulla","state":"AZ"} -{"account_number":10,"balance":46170,"firstname":"Dominique","lastname":"Park","age":37,"gender":"F","address":"100 Gatling Place","employer":"Conjurica","email":"dominiquepark@conjurica.com","city":"Omar","state":"NJ"} -{"account_number":15,"balance":43456,"firstname":"Bobbie","lastname":"Sexton","age":21,"gender":"M","address":"232 Sedgwick Place","employer":"Zytrex","email":"bobbiesexton@zytrex.com","city":"Hendersonville","state":"CA"} -{"account_number":22,"balance":40283,"firstname":"Barrera","lastname":"Terrell","age":23,"gender":"F","address":"292 Orange Street","employer":"Steelfab","email":"barreraterrell@steelfab.com","city":"Bynum","state":"ME"} -{"account_number":27,"balance":6176,"firstname":"Meyers","lastname":"Williamson","age":26,"gender":"F","address":"675 Henderson Walk","employer":"Plexia","email":"meyerswilliamson@plexia.com","city":"Richmond","state":"AZ"} -{"account_number":34,"balance":35379,"firstname":"Ellison","lastname":"Kim","age":30,"gender":"F","address":"986 Revere Place","employer":"Signity","email":"ellisonkim@signity.com","city":"Sehili","state":"IL"} -{"account_number":39,"balance":38688,"firstname":"Bowers","lastname":"Mendez","age":22,"gender":"F","address":"665 Bennet Court","employer":"Farmage","email":"bowersmendez@farmage.com","city":"Duryea","state":"PA"} -{"account_number":41,"balance":36060,"firstname":"Hancock","lastname":"Holden","age":20,"gender":"M","address":"625 Gaylord Drive","employer":"Poochies","email":"hancockholden@poochies.com","city":"Alamo","state":"KS"} -{"account_number":46,"balance":12351,"firstname":"Karla","lastname":"Bowman","age":23,"gender":"M","address":"554 Chapel Street","employer":"Undertap","email":"karlabowman@undertap.com","city":"Sylvanite","state":"DC"} -{"account_number":53,"balance":28101,"firstname":"Kathryn","lastname":"Payne","age":29,"gender":"F","address":"467 Louis Place","employer":"Katakana","email":"kathrynpayne@katakana.com","city":"Harviell","state":"SD"} -{"account_number":58,"balance":31697,"firstname":"Marva","lastname":"Cannon","age":40,"gender":"M","address":"993 Highland Place","employer":"Comcubine","email":"marvacannon@comcubine.com","city":"Orviston","state":"MO"} -{"account_number":60,"balance":45955,"firstname":"Maude","lastname":"Casey","age":31,"gender":"F","address":"566 Strauss Street","employer":"Quilch","email":"maudecasey@quilch.com","city":"Enlow","state":"GA"} -{"account_number":65,"balance":23282,"firstname":"Leonor","lastname":"Pruitt","age":24,"gender":"M","address":"974 Terrace Place","employer":"Velos","email":"leonorpruitt@velos.com","city":"Devon","state":"WI"} -{"account_number":72,"balance":9732,"firstname":"Barlow","lastname":"Rhodes","age":25,"gender":"F","address":"891 Clinton Avenue","employer":"Zialactic","email":"barlowrhodes@zialactic.com","city":"Echo","state":"TN"} -{"account_number":77,"balance":5724,"firstname":"Byrd","lastname":"Conley","age":24,"gender":"F","address":"698 Belmont Avenue","employer":"Zidox","email":"byrdconley@zidox.com","city":"Rockbridge","state":"SC"} -{"account_number":84,"balance":3001,"firstname":"Hutchinson","lastname":"Newton","age":34,"gender":"F","address":"553 Locust Street","employer":"Zaggles","email":"hutchinsonnewton@zaggles.com","city":"Snyderville","state":"DC"} -{"account_number":89,"balance":13263,"firstname":"Mcdowell","lastname":"Bradley","age":28,"gender":"M","address":"960 Howard Alley","employer":"Grok","email":"mcdowellbradley@grok.com","city":"Toftrees","state":"TX"} -{"account_number":91,"balance":29799,"firstname":"Vonda","lastname":"Galloway","age":20,"gender":"M","address":"988 Voorhies Avenue","employer":"Illumity","email":"vondagalloway@illumity.com","city":"Holcombe","state":"HI"} -{"account_number":96,"balance":15933,"firstname":"Shirley","lastname":"Edwards","age":38,"gender":"M","address":"817 Caton Avenue","employer":"Equitox","email":"shirleyedwards@equitox.com","city":"Nelson","state":"MA"} -{"account_number":104,"balance":32619,"firstname":"Casey","lastname":"Roth","age":29,"gender":"M","address":"963 Railroad Avenue","employer":"Hotcakes","email":"caseyroth@hotcakes.com","city":"Davenport","state":"OH"} -{"account_number":109,"balance":25812,"firstname":"Gretchen","lastname":"Dawson","age":31,"gender":"M","address":"610 Bethel Loop","employer":"Tetak","email":"gretchendawson@tetak.com","city":"Hailesboro","state":"CO"} -{"account_number":111,"balance":1481,"firstname":"Traci","lastname":"Allison","age":35,"gender":"M","address":"922 Bryant Street","employer":"Enjola","email":"traciallison@enjola.com","city":"Robinette","state":"OR"} -{"account_number":116,"balance":21335,"firstname":"Hobbs","lastname":"Wright","age":24,"gender":"M","address":"965 Temple Court","employer":"Netbook","email":"hobbswright@netbook.com","city":"Strong","state":"CA"} -{"account_number":123,"balance":3079,"firstname":"Cleo","lastname":"Beach","age":27,"gender":"F","address":"653 Haring Street","employer":"Proxsoft","email":"cleobeach@proxsoft.com","city":"Greensburg","state":"ME"} -{"account_number":128,"balance":3556,"firstname":"Mack","lastname":"Bullock","age":34,"gender":"F","address":"462 Ingraham Street","employer":"Terascape","email":"mackbullock@terascape.com","city":"Eureka","state":"PA"} -{"account_number":130,"balance":24171,"firstname":"Roxie","lastname":"Cantu","age":33,"gender":"M","address":"841 Catherine Street","employer":"Skybold","email":"roxiecantu@skybold.com","city":"Deputy","state":"NE"} -{"account_number":135,"balance":24885,"firstname":"Stevenson","lastname":"Crosby","age":40,"gender":"F","address":"473 Boardwalk ","employer":"Accel","email":"stevensoncrosby@accel.com","city":"Norris","state":"OK"} -{"account_number":142,"balance":4544,"firstname":"Vang","lastname":"Hughes","age":27,"gender":"M","address":"357 Landis Court","employer":"Bolax","email":"vanghughes@bolax.com","city":"Emerald","state":"WY"} -{"account_number":147,"balance":35921,"firstname":"Charmaine","lastname":"Whitney","age":28,"gender":"F","address":"484 Seton Place","employer":"Comveyer","email":"charmainewhitney@comveyer.com","city":"Dexter","state":"DC"} -{"account_number":154,"balance":40945,"firstname":"Burns","lastname":"Solis","age":31,"gender":"M","address":"274 Lorraine Street","employer":"Rodemco","email":"burnssolis@rodemco.com","city":"Ballico","state":"WI"} -{"account_number":159,"balance":1696,"firstname":"Alvarez","lastname":"Mack","age":22,"gender":"F","address":"897 Manor Court","employer":"Snorus","email":"alvarezmack@snorus.com","city":"Rosedale","state":"CA"} -{"account_number":161,"balance":4659,"firstname":"Doreen","lastname":"Randall","age":37,"gender":"F","address":"178 Court Street","employer":"Calcula","email":"doreenrandall@calcula.com","city":"Belmont","state":"TX"} -{"account_number":166,"balance":33847,"firstname":"Rutledge","lastname":"Rivas","age":23,"gender":"M","address":"352 Verona Street","employer":"Virxo","email":"rutledgerivas@virxo.com","city":"Brandermill","state":"NE"} -{"account_number":173,"balance":5989,"firstname":"Whitley","lastname":"Blevins","age":32,"gender":"M","address":"127 Brooklyn Avenue","employer":"Pawnagra","email":"whitleyblevins@pawnagra.com","city":"Rodanthe","state":"ND"} -{"account_number":178,"balance":36735,"firstname":"Clements","lastname":"Finley","age":39,"gender":"F","address":"270 Story Court","employer":"Imaginart","email":"clementsfinley@imaginart.com","city":"Lookingglass","state":"MN"} -{"account_number":180,"balance":34236,"firstname":"Ursula","lastname":"Goodman","age":32,"gender":"F","address":"414 Clinton Street","employer":"Earthmark","email":"ursulagoodman@earthmark.com","city":"Rote","state":"AR"} -{"account_number":185,"balance":43532,"firstname":"Laurel","lastname":"Cline","age":40,"gender":"M","address":"788 Fenimore Street","employer":"Prismatic","email":"laurelcline@prismatic.com","city":"Frank","state":"UT"} -{"account_number":192,"balance":23508,"firstname":"Ramsey","lastname":"Carr","age":31,"gender":"F","address":"209 Williamsburg Street","employer":"Strezzo","email":"ramseycarr@strezzo.com","city":"Grapeview","state":"NM"} -{"account_number":197,"balance":17246,"firstname":"Sweet","lastname":"Sanders","age":33,"gender":"F","address":"712 Homecrest Court","employer":"Isosure","email":"sweetsanders@isosure.com","city":"Sheatown","state":"VT"} -{"account_number":200,"balance":26210,"firstname":"Teri","lastname":"Hester","age":39,"gender":"M","address":"653 Abbey Court","employer":"Electonic","email":"terihester@electonic.com","city":"Martell","state":"MD"} -{"account_number":205,"balance":45493,"firstname":"Johnson","lastname":"Chang","age":28,"gender":"F","address":"331 John Street","employer":"Gleamink","email":"johnsonchang@gleamink.com","city":"Sultana","state":"KS"} -{"account_number":212,"balance":10299,"firstname":"Marisol","lastname":"Fischer","age":39,"gender":"M","address":"362 Prince Street","employer":"Autograte","email":"marisolfischer@autograte.com","city":"Oley","state":"SC"} -{"account_number":217,"balance":33730,"firstname":"Sally","lastname":"Mccoy","age":38,"gender":"F","address":"854 Corbin Place","employer":"Omnigog","email":"sallymccoy@omnigog.com","city":"Escondida","state":"FL"} -{"account_number":224,"balance":42708,"firstname":"Billie","lastname":"Nixon","age":28,"gender":"F","address":"241 Kaufman Place","employer":"Xanide","email":"billienixon@xanide.com","city":"Chapin","state":"NY"} -{"account_number":229,"balance":2740,"firstname":"Jana","lastname":"Hensley","age":30,"gender":"M","address":"176 Erasmus Street","employer":"Isotrack","email":"janahensley@isotrack.com","city":"Caledonia","state":"ME"} -{"account_number":231,"balance":46180,"firstname":"Essie","lastname":"Clarke","age":34,"gender":"F","address":"308 Harbor Lane","employer":"Pharmacon","email":"essieclarke@pharmacon.com","city":"Fillmore","state":"MS"} -{"account_number":236,"balance":41200,"firstname":"Suzanne","lastname":"Bird","age":39,"gender":"F","address":"219 Luquer Street","employer":"Imant","email":"suzannebird@imant.com","city":"Bainbridge","state":"NY"} -{"account_number":243,"balance":29902,"firstname":"Evangelina","lastname":"Perez","age":20,"gender":"M","address":"787 Joval Court","employer":"Keengen","email":"evangelinaperez@keengen.com","city":"Mulberry","state":"SD"} -{"account_number":248,"balance":49989,"firstname":"West","lastname":"England","age":36,"gender":"M","address":"717 Hendrickson Place","employer":"Obliq","email":"westengland@obliq.com","city":"Maury","state":"WA"} -{"account_number":250,"balance":27893,"firstname":"Earlene","lastname":"Ellis","age":39,"gender":"F","address":"512 Bay Street","employer":"Codact","email":"earleneellis@codact.com","city":"Sunwest","state":"GA"} -{"account_number":255,"balance":49339,"firstname":"Iva","lastname":"Rivers","age":38,"gender":"M","address":"470 Rost Place","employer":"Mantrix","email":"ivarivers@mantrix.com","city":"Disautel","state":"MD"} -{"account_number":262,"balance":30289,"firstname":"Tameka","lastname":"Levine","age":36,"gender":"F","address":"815 Atlantic Avenue","employer":"Acium","email":"tamekalevine@acium.com","city":"Winchester","state":"SD"} -{"account_number":267,"balance":42753,"firstname":"Weeks","lastname":"Castillo","age":21,"gender":"F","address":"526 Holt Court","employer":"Talendula","email":"weekscastillo@talendula.com","city":"Washington","state":"NV"} -{"account_number":274,"balance":12104,"firstname":"Frieda","lastname":"House","age":33,"gender":"F","address":"171 Banker Street","employer":"Quonk","email":"friedahouse@quonk.com","city":"Aberdeen","state":"NJ"} -{"account_number":279,"balance":15904,"firstname":"Chapman","lastname":"Hart","age":32,"gender":"F","address":"902 Bliss Terrace","employer":"Kongene","email":"chapmanhart@kongene.com","city":"Bradenville","state":"NJ"} -{"account_number":281,"balance":39830,"firstname":"Bean","lastname":"Aguirre","age":20,"gender":"F","address":"133 Pilling Street","employer":"Amril","email":"beanaguirre@amril.com","city":"Waterview","state":"TX"} -{"account_number":286,"balance":39063,"firstname":"Rosetta","lastname":"Turner","age":35,"gender":"M","address":"169 Jefferson Avenue","employer":"Spacewax","email":"rosettaturner@spacewax.com","city":"Stewart","state":"MO"} -{"account_number":293,"balance":29867,"firstname":"Cruz","lastname":"Carver","age":28,"gender":"F","address":"465 Boerum Place","employer":"Vitricomp","email":"cruzcarver@vitricomp.com","city":"Crayne","state":"CO"} -{"account_number":298,"balance":34334,"firstname":"Bullock","lastname":"Marsh","age":20,"gender":"M","address":"589 Virginia Place","employer":"Renovize","email":"bullockmarsh@renovize.com","city":"Coinjock","state":"UT"} -{"account_number":301,"balance":16782,"firstname":"Minerva","lastname":"Graham","age":35,"gender":"M","address":"532 Harrison Place","employer":"Sureplex","email":"minervagraham@sureplex.com","city":"Belleview","state":"GA"} -{"account_number":306,"balance":2171,"firstname":"Hensley","lastname":"Hardin","age":40,"gender":"M","address":"196 Maujer Street","employer":"Neocent","email":"hensleyhardin@neocent.com","city":"Reinerton","state":"HI"} -{"account_number":313,"balance":34108,"firstname":"Alston","lastname":"Henderson","age":36,"gender":"F","address":"132 Prescott Place","employer":"Prosure","email":"alstonhenderson@prosure.com","city":"Worton","state":"IA"} -{"account_number":318,"balance":8512,"firstname":"Nichole","lastname":"Pearson","age":34,"gender":"F","address":"656 Lacon Court","employer":"Yurture","email":"nicholepearson@yurture.com","city":"Juarez","state":"MO"} -{"account_number":320,"balance":34521,"firstname":"Patti","lastname":"Brennan","age":37,"gender":"F","address":"870 Degraw Street","employer":"Cognicode","email":"pattibrennan@cognicode.com","city":"Torboy","state":"FL"} -{"account_number":325,"balance":1956,"firstname":"Magdalena","lastname":"Simmons","age":25,"gender":"F","address":"681 Townsend Street","employer":"Geekosis","email":"magdalenasimmons@geekosis.com","city":"Sterling","state":"CA"} -{"account_number":332,"balance":37770,"firstname":"Shepherd","lastname":"Davenport","age":28,"gender":"F","address":"586 Montague Terrace","employer":"Ecraze","email":"shepherddavenport@ecraze.com","city":"Accoville","state":"NM"} -{"account_number":337,"balance":43432,"firstname":"Monroe","lastname":"Stafford","age":37,"gender":"F","address":"183 Seigel Street","employer":"Centuria","email":"monroestafford@centuria.com","city":"Camino","state":"DE"} -{"account_number":344,"balance":42654,"firstname":"Sasha","lastname":"Baxter","age":35,"gender":"F","address":"700 Bedford Place","employer":"Callflex","email":"sashabaxter@callflex.com","city":"Campo","state":"MI"} -{"account_number":349,"balance":24180,"firstname":"Allison","lastname":"Fitzpatrick","age":22,"gender":"F","address":"913 Arlington Avenue","employer":"Veraq","email":"allisonfitzpatrick@veraq.com","city":"Marbury","state":"TX"} -{"account_number":351,"balance":47089,"firstname":"Hendrix","lastname":"Stephens","age":29,"gender":"M","address":"181 Beaver Street","employer":"Recrisys","email":"hendrixstephens@recrisys.com","city":"Denio","state":"OR"} -{"account_number":356,"balance":34540,"firstname":"Lourdes","lastname":"Valdez","age":20,"gender":"F","address":"700 Anchorage Place","employer":"Interloo","email":"lourdesvaldez@interloo.com","city":"Goldfield","state":"OK"} -{"account_number":363,"balance":34007,"firstname":"Peggy","lastname":"Bright","age":21,"gender":"M","address":"613 Engert Avenue","employer":"Inventure","email":"peggybright@inventure.com","city":"Chautauqua","state":"ME"} -{"account_number":368,"balance":23535,"firstname":"Hooper","lastname":"Tyson","age":39,"gender":"M","address":"892 Taaffe Place","employer":"Zaggle","email":"hoopertyson@zaggle.com","city":"Nutrioso","state":"ME"} -{"account_number":370,"balance":28499,"firstname":"Oneill","lastname":"Carney","age":25,"gender":"F","address":"773 Adelphi Street","employer":"Bedder","email":"oneillcarney@bedder.com","city":"Yorklyn","state":"FL"} -{"account_number":375,"balance":23860,"firstname":"Phoebe","lastname":"Patton","age":25,"gender":"M","address":"564 Hale Avenue","employer":"Xoggle","email":"phoebepatton@xoggle.com","city":"Brule","state":"NM"} -{"account_number":382,"balance":42061,"firstname":"Finley","lastname":"Singleton","age":37,"gender":"F","address":"407 Clay Street","employer":"Quarex","email":"finleysingleton@quarex.com","city":"Bedias","state":"LA"} -{"account_number":387,"balance":35916,"firstname":"April","lastname":"Hill","age":29,"gender":"M","address":"818 Bayard Street","employer":"Kengen","email":"aprilhill@kengen.com","city":"Chloride","state":"NC"} -{"account_number":394,"balance":6121,"firstname":"Lorrie","lastname":"Nunez","age":38,"gender":"M","address":"221 Ralph Avenue","employer":"Bullzone","email":"lorrienunez@bullzone.com","city":"Longoria","state":"ID"} -{"account_number":399,"balance":32587,"firstname":"Carmela","lastname":"Franks","age":23,"gender":"M","address":"617 Dewey Place","employer":"Zensure","email":"carmelafranks@zensure.com","city":"Sanders","state":"DC"} -{"account_number":402,"balance":1282,"firstname":"Pacheco","lastname":"Rosales","age":32,"gender":"M","address":"538 Pershing Loop","employer":"Circum","email":"pachecorosales@circum.com","city":"Elbert","state":"ID"} -{"account_number":407,"balance":36417,"firstname":"Gilda","lastname":"Jacobson","age":29,"gender":"F","address":"883 Loring Avenue","employer":"Comveyor","email":"gildajacobson@comveyor.com","city":"Topaz","state":"NH"} -{"account_number":414,"balance":17506,"firstname":"Conway","lastname":"Daugherty","age":37,"gender":"F","address":"643 Kermit Place","employer":"Lyria","email":"conwaydaugherty@lyria.com","city":"Vaughn","state":"NV"} -{"account_number":419,"balance":34847,"firstname":"Helen","lastname":"Montoya","age":29,"gender":"F","address":"736 Kingsland Avenue","employer":"Hairport","email":"helenmontoya@hairport.com","city":"Edinburg","state":"NE"} -{"account_number":421,"balance":46868,"firstname":"Tamika","lastname":"Mccall","age":27,"gender":"F","address":"764 Bragg Court","employer":"Eventix","email":"tamikamccall@eventix.com","city":"Tivoli","state":"RI"} -{"account_number":426,"balance":4499,"firstname":"Julie","lastname":"Parsons","age":31,"gender":"M","address":"768 Keap Street","employer":"Goko","email":"julieparsons@goko.com","city":"Coldiron","state":"VA"} -{"account_number":433,"balance":19266,"firstname":"Wilkinson","lastname":"Flowers","age":39,"gender":"M","address":"154 Douglass Street","employer":"Xsports","email":"wilkinsonflowers@xsports.com","city":"Coultervillle","state":"MN"} -{"account_number":438,"balance":16367,"firstname":"Walter","lastname":"Velez","age":27,"gender":"F","address":"931 Farragut Road","employer":"Virva","email":"waltervelez@virva.com","city":"Tyro","state":"WV"} -{"account_number":440,"balance":41590,"firstname":"Ray","lastname":"Wiley","age":31,"gender":"F","address":"102 Barwell Terrace","employer":"Polaria","email":"raywiley@polaria.com","city":"Hardyville","state":"IA"} -{"account_number":445,"balance":41178,"firstname":"Rodriguez","lastname":"Macias","age":34,"gender":"M","address":"164 Boerum Street","employer":"Xylar","email":"rodriguezmacias@xylar.com","city":"Riner","state":"AL"} -{"account_number":452,"balance":3589,"firstname":"Blackwell","lastname":"Delaney","age":39,"gender":"F","address":"443 Sackett Street","employer":"Imkan","email":"blackwelldelaney@imkan.com","city":"Gasquet","state":"DC"} -{"account_number":457,"balance":14057,"firstname":"Bush","lastname":"Gordon","age":34,"gender":"M","address":"975 Dakota Place","employer":"Softmicro","email":"bushgordon@softmicro.com","city":"Chemung","state":"PA"} -{"account_number":464,"balance":20504,"firstname":"Cobb","lastname":"Humphrey","age":21,"gender":"M","address":"823 Sunnyside Avenue","employer":"Apexia","email":"cobbhumphrey@apexia.com","city":"Wintersburg","state":"NY"} -{"account_number":469,"balance":26509,"firstname":"Marci","lastname":"Shepherd","age":26,"gender":"M","address":"565 Hall Street","employer":"Shadease","email":"marcishepherd@shadease.com","city":"Springhill","state":"IL"} -{"account_number":471,"balance":7629,"firstname":"Juana","lastname":"Silva","age":36,"gender":"M","address":"249 Amity Street","employer":"Artworlds","email":"juanasilva@artworlds.com","city":"Norfolk","state":"TX"} -{"account_number":476,"balance":33386,"firstname":"Silva","lastname":"Marks","age":31,"gender":"F","address":"183 Eldert Street","employer":"Medifax","email":"silvamarks@medifax.com","city":"Hachita","state":"RI"} -{"account_number":483,"balance":6344,"firstname":"Kelley","lastname":"Harper","age":29,"gender":"M","address":"758 Preston Court","employer":"Xyqag","email":"kelleyharper@xyqag.com","city":"Healy","state":"IA"} -{"account_number":488,"balance":6289,"firstname":"Wilma","lastname":"Hopkins","age":38,"gender":"M","address":"428 Lee Avenue","employer":"Entality","email":"wilmahopkins@entality.com","city":"Englevale","state":"WI"} -{"account_number":490,"balance":1447,"firstname":"Strong","lastname":"Hendrix","age":26,"gender":"F","address":"134 Beach Place","employer":"Duoflex","email":"stronghendrix@duoflex.com","city":"Allentown","state":"ND"} -{"account_number":495,"balance":13478,"firstname":"Abigail","lastname":"Nichols","age":40,"gender":"F","address":"887 President Street","employer":"Enquility","email":"abigailnichols@enquility.com","city":"Bagtown","state":"NM"} -{"account_number":503,"balance":42649,"firstname":"Leta","lastname":"Stout","age":39,"gender":"F","address":"518 Bowery Street","employer":"Pivitol","email":"letastout@pivitol.com","city":"Boonville","state":"ND"} -{"account_number":508,"balance":41300,"firstname":"Lawrence","lastname":"Mathews","age":27,"gender":"F","address":"987 Rose Street","employer":"Deviltoe","email":"lawrencemathews@deviltoe.com","city":"Woodburn","state":"FL"} -{"account_number":510,"balance":48504,"firstname":"Petty","lastname":"Sykes","age":28,"gender":"M","address":"566 Village Road","employer":"Nebulean","email":"pettysykes@nebulean.com","city":"Wedgewood","state":"MO"} -{"account_number":515,"balance":18531,"firstname":"Lott","lastname":"Keller","age":27,"gender":"M","address":"827 Miami Court","employer":"Translink","email":"lottkeller@translink.com","city":"Gila","state":"TX"} -{"account_number":522,"balance":19879,"firstname":"Faulkner","lastname":"Garrett","age":29,"gender":"F","address":"396 Grove Place","employer":"Pigzart","email":"faulknergarrett@pigzart.com","city":"Felt","state":"AR"} -{"account_number":527,"balance":2028,"firstname":"Carver","lastname":"Peters","age":35,"gender":"M","address":"816 Victor Road","employer":"Housedown","email":"carverpeters@housedown.com","city":"Nadine","state":"MD"} -{"account_number":534,"balance":20470,"firstname":"Cristina","lastname":"Russo","age":25,"gender":"F","address":"500 Highlawn Avenue","employer":"Cyclonica","email":"cristinarusso@cyclonica.com","city":"Gorst","state":"KS"} -{"account_number":539,"balance":24560,"firstname":"Tami","lastname":"Maddox","age":23,"gender":"F","address":"741 Pineapple Street","employer":"Accidency","email":"tamimaddox@accidency.com","city":"Kennedyville","state":"OH"} -{"account_number":541,"balance":42915,"firstname":"Logan","lastname":"Burke","age":32,"gender":"M","address":"904 Clarendon Road","employer":"Overplex","email":"loganburke@overplex.com","city":"Johnsonburg","state":"OH"} -{"account_number":546,"balance":43242,"firstname":"Bernice","lastname":"Sims","age":33,"gender":"M","address":"382 Columbia Street","employer":"Verbus","email":"bernicesims@verbus.com","city":"Sena","state":"KY"} -{"account_number":553,"balance":28390,"firstname":"Aimee","lastname":"Cohen","age":28,"gender":"M","address":"396 Lafayette Avenue","employer":"Eplode","email":"aimeecohen@eplode.com","city":"Thatcher","state":"NJ"} -{"account_number":558,"balance":8922,"firstname":"Horne","lastname":"Valenzuela","age":20,"gender":"F","address":"979 Kensington Street","employer":"Isoternia","email":"hornevalenzuela@isoternia.com","city":"Greenbush","state":"NC"} -{"account_number":560,"balance":24514,"firstname":"Felecia","lastname":"Oneill","age":26,"gender":"M","address":"995 Autumn Avenue","employer":"Mediot","email":"feleciaoneill@mediot.com","city":"Joppa","state":"IN"} -{"account_number":565,"balance":15197,"firstname":"Taylor","lastname":"Ingram","age":37,"gender":"F","address":"113 Will Place","employer":"Lyrichord","email":"tayloringram@lyrichord.com","city":"Collins","state":"ME"} -{"account_number":572,"balance":49355,"firstname":"Therese","lastname":"Espinoza","age":20,"gender":"M","address":"994 Chester Court","employer":"Gonkle","email":"thereseespinoza@gonkle.com","city":"Hayes","state":"UT"} -{"account_number":577,"balance":21398,"firstname":"Gilbert","lastname":"Serrano","age":38,"gender":"F","address":"294 Troutman Street","employer":"Senmao","email":"gilbertserrano@senmao.com","city":"Greer","state":"MT"} -{"account_number":584,"balance":5346,"firstname":"Pearson","lastname":"Bryant","age":40,"gender":"F","address":"971 Heyward Street","employer":"Anacho","email":"pearsonbryant@anacho.com","city":"Bluffview","state":"MN"} -{"account_number":589,"balance":33260,"firstname":"Ericka","lastname":"Cote","age":39,"gender":"F","address":"425 Bath Avenue","employer":"Venoflex","email":"erickacote@venoflex.com","city":"Blue","state":"CT"} -{"account_number":591,"balance":48997,"firstname":"Rivers","lastname":"Macdonald","age":34,"gender":"F","address":"919 Johnson Street","employer":"Ziore","email":"riversmacdonald@ziore.com","city":"Townsend","state":"IL"} -{"account_number":596,"balance":4063,"firstname":"Letitia","lastname":"Walker","age":26,"gender":"F","address":"963 Vanderveer Place","employer":"Zizzle","email":"letitiawalker@zizzle.com","city":"Rossmore","state":"ID"} -{"account_number":604,"balance":10675,"firstname":"Isabel","lastname":"Gilliam","age":23,"gender":"M","address":"854 Broadway ","employer":"Zenthall","email":"isabelgilliam@zenthall.com","city":"Ventress","state":"WI"} -{"account_number":609,"balance":28586,"firstname":"Montgomery","lastname":"Washington","age":30,"gender":"M","address":"169 Schroeders Avenue","employer":"Kongle","email":"montgomerywashington@kongle.com","city":"Croom","state":"AZ"} -{"account_number":611,"balance":17528,"firstname":"Katherine","lastname":"Prince","age":33,"gender":"F","address":"705 Elm Avenue","employer":"Zillacon","email":"katherineprince@zillacon.com","city":"Rew","state":"MI"} -{"account_number":616,"balance":25276,"firstname":"Jessie","lastname":"Mayer","age":35,"gender":"F","address":"683 Chester Avenue","employer":"Emtrak","email":"jessiemayer@emtrak.com","city":"Marysville","state":"HI"} -{"account_number":623,"balance":20514,"firstname":"Rose","lastname":"Combs","age":32,"gender":"F","address":"312 Grimes Road","employer":"Aquamate","email":"rosecombs@aquamate.com","city":"Fostoria","state":"OH"} -{"account_number":628,"balance":42736,"firstname":"Buckner","lastname":"Chen","age":37,"gender":"M","address":"863 Rugby Road","employer":"Jamnation","email":"bucknerchen@jamnation.com","city":"Camas","state":"TX"} -{"account_number":630,"balance":46060,"firstname":"Leanne","lastname":"Jones","age":31,"gender":"M","address":"451 Bayview Avenue","employer":"Wazzu","email":"leannejones@wazzu.com","city":"Kylertown","state":"OK"} -{"account_number":635,"balance":44705,"firstname":"Norman","lastname":"Gilmore","age":33,"gender":"M","address":"330 Gates Avenue","employer":"Comfirm","email":"normangilmore@comfirm.com","city":"Riceville","state":"TN"} -{"account_number":642,"balance":32852,"firstname":"Reyna","lastname":"Harris","age":35,"gender":"M","address":"305 Powell Street","employer":"Bedlam","email":"reynaharris@bedlam.com","city":"Florence","state":"KS"} -{"account_number":647,"balance":10147,"firstname":"Annabelle","lastname":"Velazquez","age":30,"gender":"M","address":"299 Kensington Walk","employer":"Sealoud","email":"annabellevelazquez@sealoud.com","city":"Soudan","state":"ME"} -{"account_number":654,"balance":38695,"firstname":"Armstrong","lastname":"Frazier","age":25,"gender":"M","address":"899 Seeley Street","employer":"Zensor","email":"armstrongfrazier@zensor.com","city":"Cherokee","state":"UT"} -{"account_number":659,"balance":29648,"firstname":"Dorsey","lastname":"Sosa","age":40,"gender":"M","address":"270 Aberdeen Street","employer":"Daycore","email":"dorseysosa@daycore.com","city":"Chamberino","state":"SC"} -{"account_number":661,"balance":3679,"firstname":"Joanne","lastname":"Spencer","age":39,"gender":"F","address":"910 Montauk Avenue","employer":"Visalia","email":"joannespencer@visalia.com","city":"Valmy","state":"NH"} -{"account_number":666,"balance":13880,"firstname":"Mcguire","lastname":"Lloyd","age":40,"gender":"F","address":"658 Just Court","employer":"Centrexin","email":"mcguirelloyd@centrexin.com","city":"Warren","state":"MT"} -{"account_number":673,"balance":11303,"firstname":"Mcdaniel","lastname":"Harrell","age":33,"gender":"M","address":"565 Montgomery Place","employer":"Eyeris","email":"mcdanielharrell@eyeris.com","city":"Garnet","state":"NV"} -{"account_number":678,"balance":43663,"firstname":"Ruby","lastname":"Shaffer","age":28,"gender":"M","address":"350 Clark Street","employer":"Comtrail","email":"rubyshaffer@comtrail.com","city":"Aurora","state":"MA"} -{"account_number":680,"balance":31561,"firstname":"Melton","lastname":"Camacho","age":32,"gender":"F","address":"771 Montana Place","employer":"Insuresys","email":"meltoncamacho@insuresys.com","city":"Sparkill","state":"IN"} -{"account_number":685,"balance":22249,"firstname":"Yesenia","lastname":"Rowland","age":24,"gender":"F","address":"193 Dekalb Avenue","employer":"Coriander","email":"yeseniarowland@coriander.com","city":"Lupton","state":"NC"} -{"account_number":692,"balance":10435,"firstname":"Haney","lastname":"Barlow","age":21,"gender":"F","address":"267 Lenox Road","employer":"Egypto","email":"haneybarlow@egypto.com","city":"Detroit","state":"IN"} -{"account_number":697,"balance":48745,"firstname":"Mallory","lastname":"Emerson","age":24,"gender":"F","address":"318 Dunne Court","employer":"Exoplode","email":"malloryemerson@exoplode.com","city":"Montura","state":"LA"} -{"account_number":700,"balance":19164,"firstname":"Patel","lastname":"Durham","age":21,"gender":"F","address":"440 King Street","employer":"Icology","email":"pateldurham@icology.com","city":"Mammoth","state":"IL"} -{"account_number":705,"balance":28415,"firstname":"Krystal","lastname":"Cross","age":22,"gender":"M","address":"604 Drew Street","employer":"Tubesys","email":"krystalcross@tubesys.com","city":"Dalton","state":"MO"} -{"account_number":712,"balance":12459,"firstname":"Butler","lastname":"Alston","age":37,"gender":"M","address":"486 Hemlock Street","employer":"Quordate","email":"butleralston@quordate.com","city":"Verdi","state":"MS"} -{"account_number":717,"balance":29270,"firstname":"Erickson","lastname":"Mcdonald","age":31,"gender":"M","address":"873 Franklin Street","employer":"Exotechno","email":"ericksonmcdonald@exotechno.com","city":"Jessie","state":"MS"} -{"account_number":724,"balance":12548,"firstname":"Hopper","lastname":"Peck","age":31,"gender":"M","address":"849 Hendrickson Street","employer":"Uxmox","email":"hopperpeck@uxmox.com","city":"Faxon","state":"UT"} -{"account_number":729,"balance":41812,"firstname":"Katy","lastname":"Rivera","age":36,"gender":"F","address":"791 Olive Street","employer":"Blurrybus","email":"katyrivera@blurrybus.com","city":"Innsbrook","state":"MI"} -{"account_number":731,"balance":4994,"firstname":"Lorene","lastname":"Weiss","age":35,"gender":"M","address":"990 Ocean Court","employer":"Comvoy","email":"loreneweiss@comvoy.com","city":"Lavalette","state":"WI"} -{"account_number":736,"balance":28677,"firstname":"Rogers","lastname":"Mcmahon","age":21,"gender":"F","address":"423 Cameron Court","employer":"Brainclip","email":"rogersmcmahon@brainclip.com","city":"Saddlebrooke","state":"FL"} -{"account_number":743,"balance":14077,"firstname":"Susana","lastname":"Moody","age":23,"gender":"M","address":"842 Fountain Avenue","employer":"Bitrex","email":"susanamoody@bitrex.com","city":"Temperanceville","state":"TN"} -{"account_number":748,"balance":38060,"firstname":"Ford","lastname":"Branch","age":25,"gender":"M","address":"926 Cypress Avenue","employer":"Buzzness","email":"fordbranch@buzzness.com","city":"Beason","state":"DC"} -{"account_number":750,"balance":40481,"firstname":"Cherie","lastname":"Brooks","age":20,"gender":"F","address":"601 Woodhull Street","employer":"Kaggle","email":"cheriebrooks@kaggle.com","city":"Groton","state":"MA"} -{"account_number":755,"balance":43878,"firstname":"Bartlett","lastname":"Conway","age":22,"gender":"M","address":"453 Times Placez","employer":"Konnect","email":"bartlettconway@konnect.com","city":"Belva","state":"VT"} -{"account_number":762,"balance":10291,"firstname":"Amanda","lastname":"Head","age":20,"gender":"F","address":"990 Ocean Parkway","employer":"Zentury","email":"amandahead@zentury.com","city":"Hegins","state":"AR"} -{"account_number":767,"balance":26220,"firstname":"Anthony","lastname":"Sutton","age":27,"gender":"F","address":"179 Fayette Street","employer":"Xiix","email":"anthonysutton@xiix.com","city":"Iberia","state":"TN"} -{"account_number":774,"balance":35287,"firstname":"Lynnette","lastname":"Alvarez","age":38,"gender":"F","address":"991 Brightwater Avenue","employer":"Gink","email":"lynnettealvarez@gink.com","city":"Leola","state":"NC"} -{"account_number":779,"balance":40983,"firstname":"Maggie","lastname":"Pace","age":32,"gender":"F","address":"104 Harbor Court","employer":"Bulljuice","email":"maggiepace@bulljuice.com","city":"Floris","state":"MA"} -{"account_number":781,"balance":29961,"firstname":"Sanford","lastname":"Mullen","age":26,"gender":"F","address":"879 Dover Street","employer":"Zanity","email":"sanfordmullen@zanity.com","city":"Martinez","state":"TX"} -{"account_number":786,"balance":3024,"firstname":"Rene","lastname":"Vang","age":33,"gender":"M","address":"506 Randolph Street","employer":"Isopop","email":"renevang@isopop.com","city":"Vienna","state":"NJ"} -{"account_number":793,"balance":16911,"firstname":"Alford","lastname":"Compton","age":36,"gender":"M","address":"186 Veronica Place","employer":"Zyple","email":"alfordcompton@zyple.com","city":"Sugartown","state":"AK"} -{"account_number":798,"balance":3165,"firstname":"Catherine","lastname":"Ward","age":30,"gender":"F","address":"325 Burnett Street","employer":"Dreamia","email":"catherineward@dreamia.com","city":"Glenbrook","state":"SD"} -{"account_number":801,"balance":14954,"firstname":"Molly","lastname":"Maldonado","age":37,"gender":"M","address":"518 Maple Avenue","employer":"Straloy","email":"mollymaldonado@straloy.com","city":"Hebron","state":"WI"} -{"account_number":806,"balance":36492,"firstname":"Carson","lastname":"Riddle","age":31,"gender":"M","address":"984 Lois Avenue","employer":"Terrago","email":"carsonriddle@terrago.com","city":"Leland","state":"MN"} -{"account_number":813,"balance":30833,"firstname":"Ebony","lastname":"Bishop","age":20,"gender":"M","address":"487 Ridge Court","employer":"Optique","email":"ebonybishop@optique.com","city":"Fairmount","state":"WA"} -{"account_number":818,"balance":24433,"firstname":"Espinoza","lastname":"Petersen","age":26,"gender":"M","address":"641 Glenwood Road","employer":"Futurity","email":"espinozapetersen@futurity.com","city":"Floriston","state":"MD"} -{"account_number":820,"balance":1011,"firstname":"Shepard","lastname":"Ramsey","age":24,"gender":"F","address":"806 Village Court","employer":"Mantro","email":"shepardramsey@mantro.com","city":"Tibbie","state":"NV"} -{"account_number":825,"balance":49000,"firstname":"Terra","lastname":"Witt","age":21,"gender":"F","address":"590 Conway Street","employer":"Insectus","email":"terrawitt@insectus.com","city":"Forbestown","state":"AR"} -{"account_number":832,"balance":8582,"firstname":"Laura","lastname":"Gibbs","age":39,"gender":"F","address":"511 Osborn Street","employer":"Corepan","email":"lauragibbs@corepan.com","city":"Worcester","state":"KS"} -{"account_number":837,"balance":14485,"firstname":"Amy","lastname":"Villarreal","age":35,"gender":"M","address":"381 Stillwell Place","employer":"Fleetmix","email":"amyvillarreal@fleetmix.com","city":"Sanford","state":"IA"} -{"account_number":844,"balance":26840,"firstname":"Jill","lastname":"David","age":31,"gender":"M","address":"346 Legion Street","employer":"Zytrax","email":"jilldavid@zytrax.com","city":"Saticoy","state":"SC"} -{"account_number":849,"balance":16200,"firstname":"Barry","lastname":"Chapman","age":26,"gender":"M","address":"931 Dekoven Court","employer":"Darwinium","email":"barrychapman@darwinium.com","city":"Whitestone","state":"WY"} -{"account_number":851,"balance":22026,"firstname":"Henderson","lastname":"Price","age":33,"gender":"F","address":"530 Hausman Street","employer":"Plutorque","email":"hendersonprice@plutorque.com","city":"Brutus","state":"RI"} -{"account_number":856,"balance":27583,"firstname":"Alissa","lastname":"Knox","age":25,"gender":"M","address":"258 Empire Boulevard","employer":"Geologix","email":"alissaknox@geologix.com","city":"Hartsville/Hartley","state":"MN"} -{"account_number":863,"balance":23165,"firstname":"Melendez","lastname":"Fernandez","age":40,"gender":"M","address":"661 Johnson Avenue","employer":"Vixo","email":"melendezfernandez@vixo.com","city":"Farmers","state":"IL"} -{"account_number":868,"balance":27624,"firstname":"Polly","lastname":"Barron","age":22,"gender":"M","address":"129 Frank Court","employer":"Geofarm","email":"pollybarron@geofarm.com","city":"Loyalhanna","state":"ND"} -{"account_number":870,"balance":43882,"firstname":"Goff","lastname":"Phelps","age":21,"gender":"M","address":"164 Montague Street","employer":"Digigen","email":"goffphelps@digigen.com","city":"Weedville","state":"IL"} -{"account_number":875,"balance":19655,"firstname":"Mercer","lastname":"Pratt","age":24,"gender":"M","address":"608 Perry Place","employer":"Twiggery","email":"mercerpratt@twiggery.com","city":"Eggertsville","state":"MO"} -{"account_number":882,"balance":10895,"firstname":"Mari","lastname":"Landry","age":39,"gender":"M","address":"963 Gerald Court","employer":"Kenegy","email":"marilandry@kenegy.com","city":"Lithium","state":"NC"} -{"account_number":887,"balance":31772,"firstname":"Eunice","lastname":"Watts","age":36,"gender":"F","address":"707 Stuyvesant Avenue","employer":"Memora","email":"eunicewatts@memora.com","city":"Westwood","state":"TN"} -{"account_number":894,"balance":1031,"firstname":"Tyler","lastname":"Fitzgerald","age":32,"gender":"M","address":"787 Meserole Street","employer":"Jetsilk","email":"tylerfitzgerald@jetsilk.com","city":"Woodlands","state":"WV"} -{"account_number":899,"balance":32953,"firstname":"Carney","lastname":"Callahan","age":23,"gender":"M","address":"724 Kimball Street","employer":"Mangelica","email":"carneycallahan@mangelica.com","city":"Tecolotito","state":"MT"} -{"account_number":902,"balance":13345,"firstname":"Hallie","lastname":"Jarvis","age":23,"gender":"F","address":"237 Duryea Court","employer":"Anixang","email":"halliejarvis@anixang.com","city":"Boykin","state":"IN"} -{"account_number":907,"balance":12961,"firstname":"Ingram","lastname":"William","age":36,"gender":"M","address":"826 Overbaugh Place","employer":"Genmex","email":"ingramwilliam@genmex.com","city":"Kimmell","state":"AK"} -{"account_number":914,"balance":7120,"firstname":"Esther","lastname":"Bean","age":32,"gender":"F","address":"583 Macon Street","employer":"Applica","email":"estherbean@applica.com","city":"Homeworth","state":"MN"} -{"account_number":919,"balance":39655,"firstname":"Shauna","lastname":"Hanson","age":27,"gender":"M","address":"557 Hart Place","employer":"Exospace","email":"shaunahanson@exospace.com","city":"Outlook","state":"LA"} -{"account_number":921,"balance":49119,"firstname":"Barbara","lastname":"Wade","age":29,"gender":"M","address":"687 Hoyts Lane","employer":"Roughies","email":"barbarawade@roughies.com","city":"Sattley","state":"CO"} -{"account_number":926,"balance":49433,"firstname":"Welch","lastname":"Mcgowan","age":21,"gender":"M","address":"833 Quincy Street","employer":"Atomica","email":"welchmcgowan@atomica.com","city":"Hampstead","state":"VT"} -{"account_number":933,"balance":18071,"firstname":"Tabitha","lastname":"Cole","age":21,"gender":"F","address":"916 Rogers Avenue","employer":"Eclipto","email":"tabithacole@eclipto.com","city":"Lawrence","state":"TX"} -{"account_number":938,"balance":9597,"firstname":"Sharron","lastname":"Santos","age":40,"gender":"F","address":"215 Matthews Place","employer":"Zenco","email":"sharronsantos@zenco.com","city":"Wattsville","state":"VT"} -{"account_number":940,"balance":23285,"firstname":"Melinda","lastname":"Mendoza","age":38,"gender":"M","address":"806 Kossuth Place","employer":"Kneedles","email":"melindamendoza@kneedles.com","city":"Coaldale","state":"OK"} -{"account_number":945,"balance":23085,"firstname":"Hansen","lastname":"Hebert","age":33,"gender":"F","address":"287 Conduit Boulevard","employer":"Capscreen","email":"hansenhebert@capscreen.com","city":"Taycheedah","state":"AK"} -{"account_number":952,"balance":21430,"firstname":"Angelique","lastname":"Weeks","age":33,"gender":"M","address":"659 Reeve Place","employer":"Exodoc","email":"angeliqueweeks@exodoc.com","city":"Turpin","state":"MD"} -{"account_number":957,"balance":11373,"firstname":"Michael","lastname":"Giles","age":31,"gender":"M","address":"668 Court Square","employer":"Yogasm","email":"michaelgiles@yogasm.com","city":"Rosburg","state":"WV"} -{"account_number":964,"balance":26154,"firstname":"Elena","lastname":"Waller","age":34,"gender":"F","address":"618 Crystal Street","employer":"Insurety","email":"elenawaller@insurety.com","city":"Gallina","state":"NY"} -{"account_number":969,"balance":22214,"firstname":"Briggs","lastname":"Lynn","age":30,"gender":"M","address":"952 Lester Court","employer":"Quinex","email":"briggslynn@quinex.com","city":"Roland","state":"ID"} -{"account_number":971,"balance":22772,"firstname":"Gabrielle","lastname":"Reilly","age":32,"gender":"F","address":"964 Tudor Terrace","employer":"Blanet","email":"gabriellereilly@blanet.com","city":"Falmouth","state":"AL"} -{"account_number":976,"balance":31707,"firstname":"Mullen","lastname":"Tanner","age":26,"gender":"M","address":"711 Whitney Avenue","employer":"Pulze","email":"mullentanner@pulze.com","city":"Mooresburg","state":"MA"} -{"account_number":983,"balance":47205,"firstname":"Mattie","lastname":"Eaton","age":24,"gender":"F","address":"418 Allen Avenue","employer":"Trasola","email":"mattieeaton@trasola.com","city":"Dupuyer","state":"NJ"} -{"account_number":988,"balance":17803,"firstname":"Lucy","lastname":"Castro","age":34,"gender":"F","address":"425 Fleet Walk","employer":"Geekfarm","email":"lucycastro@geekfarm.com","city":"Mulino","state":"VA"} -{"account_number":990,"balance":44456,"firstname":"Kelly","lastname":"Steele","age":35,"gender":"M","address":"809 Hoyt Street","employer":"Eschoir","email":"kellysteele@eschoir.com","city":"Stewartville","state":"ID"} -{"account_number":995,"balance":21153,"firstname":"Phelps","lastname":"Parrish","age":25,"gender":"M","address":"666 Miller Place","employer":"Pearlessa","email":"phelpsparrish@pearlessa.com","city":"Brecon","state":"ME"} diff --git a/tests/test_formatter.py b/tests/test_formatter.py deleted file mode 100644 index 5bec5d4..0000000 --- a/tests/test_formatter.py +++ /dev/null @@ -1,200 +0,0 @@ -from __future__ import unicode_literals, print_function - -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - - -import mock -import pytest -from collections import namedtuple - -from src.opensearch_sql_cli.opensearchsql_cli import OpenSearchSqlCli, COLOR_CODE_REGEX -from src.opensearch_sql_cli.formatter import Formatter -from src.opensearch_sql_cli.utils import OutputSettings - - -class TestFormatter: - @pytest.fixture - def pset_pager_mocks(self): - cli = OpenSearchSqlCli() - with mock.patch("src.opensearch_sql_cli.main.click.echo") as mock_echo, mock.patch( - "src.opensearch_sql_cli.main.click.echo_via_pager" - ) as mock_echo_via_pager, mock.patch.object(cli, "prompt_app") as mock_app: - yield cli, mock_echo, mock_echo_via_pager, mock_app - - termsize = namedtuple("termsize", ["rows", "columns"]) - test_line = "-" * 10 - test_data = [ - (10, 10, "\n".join([test_line] * 7)), - (10, 10, "\n".join([test_line] * 6)), - (10, 10, "\n".join([test_line] * 5)), - (10, 10, "-" * 11), - (10, 10, "-" * 10), - (10, 10, "-" * 9), - ] - - use_pager_when_on = [True, True, False, True, False, False] - - test_ids = [ - "Output longer than terminal height", - "Output equal to terminal height", - "Output shorter than terminal height", - "Output longer than terminal width", - "Output equal to terminal width", - "Output shorter than terminal width", - ] - - pager_test_data = [l + (r,) for l, r in zip(test_data, use_pager_when_on)] - - def test_format_output(self): - settings = OutputSettings(table_format="psql") - formatter = Formatter(settings) - data = { - "schema": [{"name": "name", "type": "text"}, {"name": "age", "type": "long"}], - "total": 1, - "datarows": [["Tim", 24]], - "size": 1, - "status": 200, - } - - results = formatter.format_output(data) - - expected = [ - "fetched rows / total rows = 1/1", - "+------+-----+", - "| name | age |", - "|------+-----|", - "| Tim | 24 |", - "+------+-----+", - ] - assert list(results) == expected - - def test_format_alias_output(self): - settings = OutputSettings(table_format="psql") - formatter = Formatter(settings) - data = { - "schema": [{"name": "name", "alias": "n", "type": "text"}], - "total": 1, - "datarows": [["Tim"]], - "size": 1, - "status": 200, - } - - results = formatter.format_output(data) - - expected = [ - "fetched rows / total rows = 1/1", - "+-----+", - "| n |", - "|-----|", - "| Tim |", - "+-----+", - ] - assert list(results) == expected - - def test_format_array_output(self): - settings = OutputSettings(table_format="psql") - formatter = Formatter(settings) - data = { - "schema": [{"name": "name", "type": "text"}, {"name": "age", "type": "long"}], - "total": 1, - "datarows": [["Tim", [24, 25]]], - "size": 1, - "status": 200, - } - - results = formatter.format_output(data) - - expected = [ - "fetched rows / total rows = 1/1", - "+------+---------+", - "| name | age |", - "|------+---------|", - "| Tim | [24,25] |", - "+------+---------+", - ] - assert list(results) == expected - - def test_format_output_vertical(self): - settings = OutputSettings(table_format="psql", max_width=1) - formatter = Formatter(settings) - data = { - "schema": [{"name": "name", "type": "text"}, {"name": "age", "type": "long"}], - "total": 1, - "datarows": [["Tim", 24]], - "size": 1, - "status": 200, - } - - expanded = [ - "fetched rows / total rows = 1/1", - "-[ RECORD 1 ]-------------------------", - "name | Tim", - "age | 24", - ] - - with mock.patch("src.opensearch_sql_cli.main.click.secho") as mock_secho, mock.patch( - "src.opensearch_sql_cli.main.click.confirm" - ) as mock_confirm: - expanded_results = formatter.format_output(data) - - mock_secho.assert_called_with(message="Output longer than terminal width", fg="red") - mock_confirm.assert_called_with("Do you want to display data vertically for better visual effect?") - - assert "\n".join(expanded_results) == "\n".join(expanded) - - def test_fake_large_output(self): - settings = OutputSettings(table_format="psql") - formatter = Formatter(settings) - fake_large_data = { - "schema": [{"name": "name", "type": "text"}, {"name": "age", "type": "long"}], - "total": 1000, - "datarows": [["Tim", [24, 25]]], - "size": 200, - "status": 200, - } - - results = formatter.format_output(fake_large_data) - - expected = [ - "fetched rows / total rows = 200/1000\n" - "Attention: Use LIMIT keyword when retrieving more than 200 rows of data", - "+------+---------+", - "| name | age |", - "|------+---------|", - "| Tim | [24,25] |", - "+------+---------+", - ] - assert list(results) == expected - - @pytest.mark.parametrize("term_height,term_width,text,use_pager", pager_test_data, ids=test_ids) - def test_pager(self, term_height, term_width, text, use_pager, pset_pager_mocks): - cli, mock_echo, mock_echo_via_pager, mock_cli = pset_pager_mocks - mock_cli.output.get_size.return_value = self.termsize(rows=term_height, columns=term_width) - - cli.echo_via_pager(text) - - if use_pager: - mock_echo.assert_not_called() - mock_echo_via_pager.assert_called() - else: - mock_echo_via_pager.assert_not_called() - mock_echo.assert_called() - - @pytest.mark.parametrize( - "text,expected_length", - [ - ( - "22200K .......\u001b[0m\u001b[91m... .......... ...\u001b[0m\u001b[91m.\u001b[0m\u001b[91m...... " - ".........\u001b[0m\u001b[91m.\u001b[0m\u001b[91m \u001b[0m\u001b[91m.\u001b[0m\u001b[91m.\u001b[" - "0m\u001b[91m.\u001b[0m\u001b[91m.\u001b[0m\u001b[91m...... 50% 28.6K 12m55s", - 78, - ), - ("=\u001b[m=", 2), - ("-\u001b]23\u0007-", 2), - ], - ) - def test_color_pattern(self, text, expected_length): - assert len(COLOR_CODE_REGEX.sub("", text)) == expected_length diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index ea4fe8f..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - -import mock -from textwrap import dedent - -from click.testing import CliRunner - -from .utils import estest, load_data, TEST_INDEX_NAME -from src.opensearch_sql_cli.main import cli -from src.opensearch_sql_cli.opensearchsql_cli import OpenSearchSqlCli - -INVALID_ENDPOINT = "http://invalid:9200" -ENDPOINT = "http://localhost:9200" -# In OS >= 2.17, limit defaults to 10k, otherwise it's 200. Specify manually for consistency. -QUERY = "select * from %s LIMIT 150" % TEST_INDEX_NAME - - -class TestMain: - @estest - def test_explain(self, connection): - doc = {"a": "aws"} - load_data(connection, doc) - - err_message = "Can not connect to endpoint %s" % INVALID_ENDPOINT - expected_output = { - "root": { - "name": "ProjectOperator", - "description": {"fields": "[a]"}, - "children": [ - { - "name": "OpenSearchIndexScan", - "description": { - "request": 'OpenSearchQueryRequest(indexName=opensearchsql_cli_test, sourceBuilder={"from":0,"size":150,"timeout":"1m","_source":{"includes":["a"],"excludes":[]}}, searchDone=false)' - }, - "children": [], - } - ], - } - } - expected_tabular_output = dedent( - """\ - fetched rows / total rows = 1/1 - +-----+ - | a | - |-----| - | aws | - +-----+""" - ) - - with mock.patch("src.opensearch_sql_cli.main.click.echo") as mock_echo, mock.patch( - "src.opensearch_sql_cli.main.click.secho" - ) as mock_secho: - runner = CliRunner() - - # test -q -e - result = runner.invoke(cli, [f"-q{QUERY}", "-e"]) - mock_echo.assert_called_with(expected_output) - assert result.exit_code == 0 - - # test -q - result = runner.invoke(cli, [f"-q{QUERY}"]) - mock_echo.assert_called_with(expected_tabular_output) - assert result.exit_code == 0 - - # test invalid endpoint - runner.invoke(cli, [INVALID_ENDPOINT, f"-q{QUERY}", "-e"]) - mock_secho.assert_called_with(message=err_message, fg="red") - - @estest - def test_cli(self): - with mock.patch.object(OpenSearchSqlCli, "connect") as mock_connect, mock.patch.object( - OpenSearchSqlCli, "run_cli" - ) as mock_run_cli: - runner = CliRunner() - result = runner.invoke(cli) - - mock_connect.assert_called_with(ENDPOINT, None) - mock_run_cli.asset_called() - assert result.exit_code == 0 diff --git a/tests/test_opensearch_connection.py b/tests/test_opensearch_connection.py deleted file mode 100644 index 7574088..0000000 --- a/tests/test_opensearch_connection.py +++ /dev/null @@ -1,146 +0,0 @@ -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - -import pytest -import mock -from textwrap import dedent - -from opensearchpy.exceptions import ConnectionError -from opensearchpy import OpenSearch, RequestsHttpConnection - -from .utils import estest, load_data, run, TEST_INDEX_NAME -from src.opensearch_sql_cli.opensearch_connection import OpenSearchConnection - -INVALID_ENDPOINT = "http://invalid:9200" -OPENSEARCH_ENDPOINT = "https://opensearch:9200" -AES_ENDPOINT = "https://fake.es.amazonaws.com" -AUTH = ("username", "password") - - -class TestExecutor: - def load_data_to_es(self, connection): - doc = {"a": "aws"} - load_data(connection, doc) - - @estest - def test_query(self, connection): - self.load_data_to_es(connection) - - assert run(connection, "select * from %s" % TEST_INDEX_NAME) == dedent( - """\ - fetched rows / total rows = 1/1 - +-----+ - | a | - |-----| - | aws | - +-----+""" - ) - - @estest - @pytest.mark.skip(reason="Test is not compatible with OpenSearch >= 2.3.0, it returns HTTP/503 instead of HTTP/400") - def test_query_nonexistent_index(self, connection): - self.load_data_to_es(connection) - - expected = { - "reason": "Error occurred in OpenSearch engine: no such index [non-existed]", - "details": "org.opensearch.index.IndexNotFoundException: no such index [non-existed]\nFor more " - "details, please send request for Json format to see the raw response from OpenSearch " - "engine.", - "type": "IndexNotFoundException", - } - - with mock.patch("src.opensearch_sql_cli.opensearch_connection.click.secho") as mock_secho: - run(connection, "select * from non-existed") - - mock_secho.assert_called_with(message=str(expected), fg="red") - - def test_connection_fail(self): - test_executor = OpenSearchConnection(endpoint=INVALID_ENDPOINT) - err_message = "Can not connect to endpoint %s" % INVALID_ENDPOINT - - with mock.patch("sys.exit") as mock_sys_exit, mock.patch( - "src.opensearch_sql_cli.opensearch_connection.click.secho" - ) as mock_secho: - test_executor.set_connection() - - mock_sys_exit.assert_called() - mock_secho.assert_called_with(message=err_message, fg="red") - - def test_lost_connection(self): - test_esexecutor = OpenSearchConnection(endpoint=INVALID_ENDPOINT) - - def side_effect_set_connection(is_reconnected): - if is_reconnected: - pass - else: - return ConnectionError() - - with mock.patch("src.opensearch_sql_cli.opensearch_connection.click.secho") as mock_secho, mock.patch.object( - test_esexecutor, "set_connection" - ) as mock_set_connection: - # Assume reconnection success - mock_set_connection.side_effect = side_effect_set_connection(is_reconnected=True) - test_esexecutor.handle_server_close_connection() - - mock_secho.assert_any_call(message="Reconnecting...", fg="green") - mock_secho.assert_any_call(message="Reconnected! Please run query again", fg="green") - # Assume reconnection fail - mock_set_connection.side_effect = side_effect_set_connection(is_reconnected=False) - test_esexecutor.handle_server_close_connection() - - mock_secho.assert_any_call(message="Reconnecting...", fg="green") - mock_secho.assert_any_call( - message="Connection Failed. Check your OpenSearch is running and then come back", fg="red" - ) - - def test_reconnection_exception(self): - test_executor = OpenSearchConnection(endpoint=INVALID_ENDPOINT) - - with pytest.raises(ConnectionError) as error: - assert test_executor.set_connection(True) - - def test_select_client(self): - od_test_executor = OpenSearchConnection(endpoint=OPENSEARCH_ENDPOINT, http_auth=AUTH) - aes_test_executor = OpenSearchConnection(endpoint=AES_ENDPOINT, use_aws_authentication=True) - - with mock.patch.object(od_test_executor, "get_opensearch_client") as mock_od_client, mock.patch.object( - OpenSearchConnection, "is_sql_plugin_installed", return_value=True - ): - od_test_executor.set_connection() - mock_od_client.assert_called() - - with mock.patch.object(aes_test_executor, "get_aes_client") as mock_aes_client, mock.patch.object( - OpenSearchConnection, "is_sql_plugin_installed", return_value=True - ): - aes_test_executor.set_connection() - mock_aes_client.assert_called() - - def test_get_od_client(self): - od_test_executor = OpenSearchConnection(endpoint=OPENSEARCH_ENDPOINT, http_auth=AUTH) - - with mock.patch.object(OpenSearch, "__init__", return_value=None) as mock_es: - od_test_executor.get_opensearch_client() - - mock_es.assert_called_with( - [OPENSEARCH_ENDPOINT], - http_auth=AUTH, - verify_certs=False, - ssl_context=od_test_executor.ssl_context, - connection_class=RequestsHttpConnection, - ) - - def test_get_aes_client(self): - aes_test_executor = OpenSearchConnection(endpoint=AES_ENDPOINT, use_aws_authentication=True) - - with mock.patch.object(OpenSearch, "__init__", return_value=None) as mock_es: - aes_test_executor.get_aes_client() - - mock_es.assert_called_with( - hosts=[AES_ENDPOINT], - http_auth=aes_test_executor.aws_auth, - use_ssl=True, - verify_certs=True, - connection_class=RequestsHttpConnection, - ) diff --git a/tests/test_opensearch_serverless.py b/tests/test_opensearch_serverless.py deleted file mode 100644 index a483e36..0000000 --- a/tests/test_opensearch_serverless.py +++ /dev/null @@ -1,36 +0,0 @@ -import os -import pytest -from opensearch_sql_cli.opensearch_connection import OpenSearchConnection -import vcr - - -sql_cli_vcr = vcr.VCR( - cassette_library_dir="tests/cassettes", - record_mode="once", - match_on=["method", "path", "query", "body"], -) - -class TestServerless: - @pytest.fixture(scope="function") - def aws_serverless_credentials(self): - os.environ["TEST_ENDPOINT_URL"] = "https://example_endpoint.beta-us-east-1.aoss.amazonaws.com:443" - os.environ["AWS_ACCESS_KEY_ID"] = "testing" - os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" - os.environ["AWS_SESSION_TOKEN"] = "testing" - - - def test_show_tables(self, aws_serverless_credentials): - with sql_cli_vcr.use_cassette("serverless_show_tables.yaml"): - aes_test_executor = OpenSearchConnection(endpoint=os.environ["TEST_ENDPOINT_URL"], use_aws_authentication=True) - aes_test_executor.set_connection() - - response = aes_test_executor.client.transport.perform_request( - method="POST", - url="/_plugins/_sql", - body={"query": "SHOW TABLES LIKE %"}, - headers={"Content-Type": "application/json", "Accept-Charset": "UTF-8"}, - ) - - assert response["status"] == 200 - assert response["total"] == 4 - assert response["datarows"][0][2] == ".opensearch_dashboards_1" diff --git a/tests/test_opensearchsql_cli.py b/tests/test_opensearchsql_cli.py deleted file mode 100644 index 40d22fd..0000000 --- a/tests/test_opensearchsql_cli.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - -import mock -import pytest -from prompt_toolkit.shortcuts import PromptSession -from prompt_toolkit.input.defaults import create_pipe_input - -from src.opensearch_sql_cli.opensearch_buffer import opensearch_is_multiline -from .utils import estest, load_data, TEST_INDEX_NAME, ENDPOINT -from src.opensearch_sql_cli.opensearchsql_cli import OpenSearchSqlCli -from src.opensearch_sql_cli.opensearch_connection import OpenSearchConnection -from src.opensearch_sql_cli.opensearch_style import style_factory - -AUTH = None -QUERY_WITH_CTRL_D = "select * from %s;\r\x04\r" % TEST_INDEX_NAME -USE_AWS_CREDENTIALS = False -QUERY_LANGUAGE = "sql" -RESPONSE_TIMEOUT = 10 - - -@pytest.fixture() -def cli(default_config_location): - return OpenSearchSqlCli(clirc_file=default_config_location, always_use_pager=False) - - -class TestOpenSearchSqlCli: - def test_connect(self, cli): - with mock.patch.object( - OpenSearchConnection, "__init__", return_value=None - ) as mock_OpenSearchConnection, mock.patch.object( - OpenSearchConnection, "set_connection" - ) as mock_set_connectiuon: - cli.connect(endpoint=ENDPOINT) - - mock_OpenSearchConnection.assert_called_with(ENDPOINT, AUTH, USE_AWS_CREDENTIALS, QUERY_LANGUAGE, - RESPONSE_TIMEOUT) - mock_set_connectiuon.assert_called() - - @estest - @pytest.mark.skip(reason="due to prompt_toolkit throwing error, no way of currently testing this") - def test_run_cli(self, connection, cli, capsys): - doc = {"a": "aws"} - load_data(connection, doc) - - # the title is colored by formatter - expected = ( - "fetched rows / total rows = 1/1" "\n+-----+\n| \x1b[38;5;47;01ma\x1b[39;00m |\n|-----|\n| aws |\n+-----+" - ) - - with mock.patch.object(OpenSearchSqlCli, "echo_via_pager") as mock_pager, mock.patch.object( - cli, "build_cli" - ) as mock_prompt: - inp = create_pipe_input() - inp.send_text(QUERY_WITH_CTRL_D) - - mock_prompt.return_value = PromptSession( - input=inp, multiline=opensearch_is_multiline(cli), style=style_factory(cli.syntax_style, cli.cli_style) - ) - - cli.connect(ENDPOINT) - cli.run_cli() - out, err = capsys.readouterr() - inp.close() - - mock_pager.assert_called_with(expected) - assert out.__contains__("Endpoint: %s" % ENDPOINT) - assert out.__contains__("See you next search!") diff --git a/tests/test_plan.md b/tests/test_plan.md deleted file mode 100644 index 5691bcf..0000000 --- a/tests/test_plan.md +++ /dev/null @@ -1,59 +0,0 @@ -# Test Plan - The purpose of this checklist is to guide you through the basic usage of OpenSearch SQL CLI, as well as a manual test process. - - -## Display - -* [ ] Auto-completion - * SQL syntax auto-completion - * index name auto-completion -* [ ] Test pagination with different output length / width. - * query for long results to see the pagination activated automatically. -* [ ] Test table formatted output. -* [ ] Test successful conversion from horizontal to vertical display with confirmation. - * resize the terminal window before launching sql cli, there will be a warning message if your terminal is too narrow for horizontal output. It will ask if you want to convert to vertical display -* [ ] Test warning message when output > 200 rows of data. (Limited by OpenSearch SQL syntax) - * `SELECT * FROM accounts` - * Run above command, you’ll see the max output is 200, and there will be a message at the top of your results telling you how much data was fetched. - * If you want to query more than 200 rows of data, try add a `LIMIT` with more than 200. - - -## Connection - -* [ ] Test connection to a local OpenSearch instance - * [ ] OpenSearch, no authentication - * [ ] OpenSearch, install [OpenSearch Security plugin](https://opensearch.org/docs/latest/security/) to enable authentication and SSL - * Run command like `opensearchsql -u -w ` to connect to instance with authentication. -* [ ] Test connection to [Amazon Elasticsearch domain](https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-gsg.html) with -[Fine Grained Access Control](https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/fgac.html) enabled. - * Have your aws credentials correctly configured by `aws configure` - * `opensearchsql --aws-auth -u -w ` -* [ ] Test connection fail when connecting to invalid endpoint. - * `opensearchsql invalidendpoint.com` - - -## Execution - -* [ ] Test successful execution given a query. e.g. - * `SELECT * FROM bank WHERE age >30 AND gender = 'm'` -* [ ] Test unsuccessful execution with an invalid SQL query will give an error message -* [ ] Test load config file - * `vim .config/opensearchsql-cli/config` - * change settings such as `table_format = github` - * restart sql cli, check the tabular output change - - -## Query Options - -* [ ] Test explain option -e - * `opensearchsql -q "SELECT * FROM accounts LIMIT 5;" -e` -* [ ] Test query and format option -q, -f - * `opensearchsql -q "SELECT * FROM accounts LIMIT 5;" -f csv` -* [ ] Test vertical output option -v - * `opensearchsql -q "SELECT * FROM accounts LIMIT 5;" -v` - -## OS and Python Version compatibility - -* [ ] Manually test on Linux(Ubuntu) and MacOS -* [ ] Test against python 3.X versions (optional) - diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index e9ecee6..0000000 --- a/tests/utils.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Copyright OpenSearch Contributors -SPDX-License-Identifier: Apache-2.0 -""" - -import json -import pytest -from opensearchpy import ConnectionError, helpers, ConnectionPool - -from src.opensearch_sql_cli.opensearch_connection import OpenSearchConnection -from src.opensearch_sql_cli.utils import OutputSettings -from src.opensearch_sql_cli.formatter import Formatter - -TEST_INDEX_NAME = "opensearchsql_cli_test" -ENDPOINT = "http://localhost:9200" - - -def create_index(test_executor): - opensearch = test_executor.client - opensearch.indices.create(index=TEST_INDEX_NAME) - - -def delete_index(test_executor): - opensearch = test_executor.client - opensearch.indices.delete(index=TEST_INDEX_NAME) - - -def close_connection(opensearch): - ConnectionPool.close(opensearch) - - -def load_file(test_executor, filename="accounts.json"): - opensearch = test_executor.client - - filepath = "./test_data/" + filename - - # generate iterable data - def load_json(): - with open(filepath, "r") as f: - for line in f: - yield json.loads(line) - - helpers.bulk(opensearch, load_json(), index=TEST_INDEX_NAME) - - -def load_data(test_executor, doc): - opensearch = test_executor.client - opensearch.index(index=TEST_INDEX_NAME, body=doc) - opensearch.indices.refresh(index=TEST_INDEX_NAME) - - -def get_connection(): - test_es_connection = OpenSearchConnection(endpoint=ENDPOINT) - test_es_connection.set_connection(is_reconnect=True) - - return test_es_connection - - -def run(test_executor, query, use_console=True): - data = test_executor.execute_query(query=query, use_console=use_console) - settings = OutputSettings(table_format="psql") - formatter = Formatter(settings) - - if data: - res = formatter.format_output(data) - res = "\n".join(res) - - return res - - -# build client for testing -try: - connection = get_connection() - CAN_CONNECT_TO_ES = True - -except ConnectionError: - CAN_CONNECT_TO_ES = False - -# use @estest annotation to mark test functions -estest = pytest.mark.skipif( - not CAN_CONNECT_TO_ES, reason="Need a OpenSearch server running at localhost:9200 accessible" -) diff --git a/tox.ini b/tox.ini index b0888c5..e996b9c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,18 @@ [tox] -envlist = py38 +envlist = py312 [testenv] -deps = pytest==4.6.3 - mock==3.0.5 - pexpect==3.3 - pytest-vcr==1.0.2 -commands = pytest \ No newline at end of file +deps = pytest==8.4.1 + mock==5.2.0 + vcrpy==7.0.0 +commands = pytest +[pytest] +norecursedirs = remote +filterwarnings = + ignore::DeprecationWarning:requests_aws4auth.* + ignore::DeprecationWarning:pkg_resources.* + ignore::DeprecationWarning:typer.params.* + ignore::DeprecationWarning:pyfiglet.* + ignore:pkg_resources is deprecated as an API:DeprecationWarning + ignore:The 'is_flag' and 'flag_value' parameters are not supported by Typer:DeprecationWarning + ignore:datetime.datetime.utcnow.*:DeprecationWarning + ignore:Connecting to .* using SSL with verify_certs=False is insecure:UserWarning