csvql-query 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +180 -0
- package/index.js +382 -0
- package/package.json +44 -0
package/index.d.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options accepted by {@link query} and {@link queryCsv}.
|
|
3
|
+
*/
|
|
4
|
+
export interface QueryOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Source field delimiter when the file is not comma-separated.
|
|
7
|
+
* Use `'\t'` for TSV, `'|'` for pipe-delimited, `';'` for semicolon, etc.
|
|
8
|
+
* The conversion is quote-aware — quoted fields containing the delimiter
|
|
9
|
+
* are handled correctly.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* query("SELECT * FROM 'data.tsv'", { delimiter: '\t' })
|
|
13
|
+
*/
|
|
14
|
+
delimiter?: string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Skip lines whose content starts with this string before running the query.
|
|
18
|
+
* Useful for files that include shell-style comments or metadata headers.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* query("SELECT * FROM 'data.csv'", { comment: '#' })
|
|
22
|
+
*/
|
|
23
|
+
comment?: string;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* When `true`, blank and whitespace-only lines are stripped before querying.
|
|
27
|
+
* The engine already ignores truly empty lines; this option also removes
|
|
28
|
+
* lines that contain only spaces or tabs.
|
|
29
|
+
*
|
|
30
|
+
* @default false
|
|
31
|
+
*/
|
|
32
|
+
skipEmptyLines?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run a SQL query against one or more CSV/TSV files and return results
|
|
37
|
+
* as an array of objects.
|
|
38
|
+
*
|
|
39
|
+
* The Zig/SIMD engine streams through the file without loading all rows into
|
|
40
|
+
* memory — RAM stays ~5 MB regardless of file size. Numeric values come back
|
|
41
|
+
* as JS numbers (via JSON.parse); everything else is a string.
|
|
42
|
+
*
|
|
43
|
+
* @param sql SQL query. File paths go inside FROM/JOIN clauses, e.g.
|
|
44
|
+
* `"SELECT * FROM 'data.csv' WHERE age > 30"`
|
|
45
|
+
* @param opts Optional pre-processing options (delimiter, comment, skipEmptyLines).
|
|
46
|
+
* @throws {Error} on invalid SQL, file not found, or engine error.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* // Basic query
|
|
50
|
+
* const rows = query("SELECT name, city FROM 'employees.csv' LIMIT 10")
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* // Filter + aggregate
|
|
54
|
+
* const rows = query(
|
|
55
|
+
* "SELECT city, COUNT(*) as n, AVG(salary) as avg FROM 'employees.csv' GROUP BY city ORDER BY avg DESC"
|
|
56
|
+
* )
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* // JOIN two files
|
|
60
|
+
* const rows = query(
|
|
61
|
+
* "SELECT e.name, d.name as dept FROM 'employees.csv' e JOIN 'departments.csv' d ON e.dept_id = d.id"
|
|
62
|
+
* )
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* // TSV input
|
|
66
|
+
* const rows = query("SELECT * FROM 'scores.tsv' WHERE score > 90", { delimiter: '\t' })
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* // Strip comment and blank lines
|
|
70
|
+
* const rows = query("SELECT * FROM 'data.csv'", { comment: '#', skipEmptyLines: true })
|
|
71
|
+
*/
|
|
72
|
+
export function query(sql: string, opts?: QueryOptions): Record<string, unknown>[];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Run a SQL query and return results as a CSV string (header row + data rows).
|
|
76
|
+
*
|
|
77
|
+
* Useful for piping results into another CSV consumer or writing to a file
|
|
78
|
+
* without the overhead of JSON serialisation.
|
|
79
|
+
*
|
|
80
|
+
* @param sql SQL query.
|
|
81
|
+
* @param opts Optional pre-processing options — same as {@link query}.
|
|
82
|
+
* @returns CSV text, e.g. `"name,salary\nAlice,120000\nBob,80000\n"`
|
|
83
|
+
* @throws {Error} on invalid SQL, file not found, or engine error.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* const csv = queryCsv("SELECT city, COUNT(*) FROM 'data.csv' GROUP BY city")
|
|
87
|
+
* fs.writeFileSync('summary.csv', csv)
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* // TSV → CSV
|
|
91
|
+
* const csv = queryCsv("SELECT name, score FROM 'results.tsv' ORDER BY score DESC", { delimiter: '\t' })
|
|
92
|
+
*/
|
|
93
|
+
export function queryCsv(sql: string, opts?: QueryOptions): string;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Options for {@link find} — no SQL required.
|
|
97
|
+
*/
|
|
98
|
+
export interface FindOptions {
|
|
99
|
+
/**
|
|
100
|
+
* Columns to return. Accepts a comma-separated string (`'name, city'`) or an array
|
|
101
|
+
* (`['name', 'city']`). Defaults to all columns when omitted.
|
|
102
|
+
*/
|
|
103
|
+
columns?: string | string[];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Filter rows without writing SQL. Supported operators: `=` `!=` `>` `>=` `<` `<=`
|
|
107
|
+
* Combine conditions with `AND` / `OR`.
|
|
108
|
+
* String values are quoted automatically; numbers stay unquoted.
|
|
109
|
+
*
|
|
110
|
+
* @example 'salary>100000'
|
|
111
|
+
* @example 'department=Engineering AND salary>80000'
|
|
112
|
+
* @example 'city=Austin OR city=Boston'
|
|
113
|
+
*/
|
|
114
|
+
where?: string;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Maximum number of rows to return. Omit or set to 0 for all rows.
|
|
118
|
+
*/
|
|
119
|
+
limit?: number;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Sort column and direction. Format: `'column'` (asc by default) or `'column:desc'`.
|
|
123
|
+
*
|
|
124
|
+
* @example 'salary:desc'
|
|
125
|
+
* @example 'name:asc'
|
|
126
|
+
*/
|
|
127
|
+
orderBy?: string;
|
|
128
|
+
|
|
129
|
+
/** Source field delimiter when the file is not comma-separated. E.g. `'\t'` for TSV. */
|
|
130
|
+
delimiter?: string;
|
|
131
|
+
|
|
132
|
+
/** Skip lines starting with this string. E.g. `'#'` for comment rows. */
|
|
133
|
+
comment?: string;
|
|
134
|
+
|
|
135
|
+
/** Strip blank / whitespace-only lines before querying. Default `false`. */
|
|
136
|
+
skipEmptyLines?: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Query a CSV file without writing SQL.
|
|
141
|
+
*
|
|
142
|
+
* Accepts simple filter, column, sort, and limit options. Internally translated
|
|
143
|
+
* to SQL and executed by the same SIMD engine as {@link query}.
|
|
144
|
+
*
|
|
145
|
+
* For aggregates (COUNT, SUM, AVG, GROUP BY, JOIN) use {@link query} instead.
|
|
146
|
+
*
|
|
147
|
+
* @param file Path to the CSV/TSV file.
|
|
148
|
+
* @param opts Filter, column, sort, limit, and pre-processing options.
|
|
149
|
+
* @returns Array of row objects.
|
|
150
|
+
* @throws {Error} on invalid condition, file not found, or engine error.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* // All rows
|
|
154
|
+
* find('employees.csv')
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* // Pick columns + filter
|
|
158
|
+
* find('employees.csv', {
|
|
159
|
+
* columns: ['name', 'city', 'salary'],
|
|
160
|
+
* where: 'salary>100000',
|
|
161
|
+
* })
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* // Filter + sort + limit
|
|
165
|
+
* find('employees.csv', {
|
|
166
|
+
* where: 'department=Engineering AND salary>80000',
|
|
167
|
+
* orderBy: 'salary:desc',
|
|
168
|
+
* limit: 10,
|
|
169
|
+
* })
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* // TSV file
|
|
173
|
+
* find('scores.tsv', {
|
|
174
|
+
* columns: ['name', 'score'],
|
|
175
|
+
* where: 'score>=90',
|
|
176
|
+
* orderBy: 'score:desc',
|
|
177
|
+
* delimiter: '\t',
|
|
178
|
+
* })
|
|
179
|
+
*/
|
|
180
|
+
export function find(file: string, opts?: FindOptions): Record<string, unknown>[];
|
package/index.js
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* csvql — fast SQL queries on CSV files from Node.js
|
|
5
|
+
*
|
|
6
|
+
* Uses the csvql Zig/SIMD engine via N-API. The engine streams through the
|
|
7
|
+
* file without loading it into memory, so RAM usage stays ~5 MB regardless
|
|
8
|
+
* of file size.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const csvql = require('csvql-query');
|
|
12
|
+
*
|
|
13
|
+
* // Basic query
|
|
14
|
+
* const rows = csvql.query("SELECT * FROM 'data.csv' WHERE salary > 100000");
|
|
15
|
+
*
|
|
16
|
+
* // TSV file
|
|
17
|
+
* const rows = csvql.query("SELECT * FROM 'data.tsv'", { delimiter: '\t' });
|
|
18
|
+
*
|
|
19
|
+
* // CSV with comment/blank rows
|
|
20
|
+
* const rows = csvql.query("SELECT * FROM 'data.csv'", { comment: '#', skipEmptyLines: true });
|
|
21
|
+
*
|
|
22
|
+
* // Return as CSV string
|
|
23
|
+
* const csv = csvql.queryCsv("SELECT city, COUNT(*) FROM 'data.csv' GROUP BY city");
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const os = require('os');
|
|
29
|
+
const crypto = require('crypto');
|
|
30
|
+
|
|
31
|
+
function findNative() {
|
|
32
|
+
// 1. Bundled in same dir (npm run build copies it here, or local publish)
|
|
33
|
+
const bundled = path.join(__dirname, 'csvql.node');
|
|
34
|
+
if (fs.existsSync(bundled)) return bundled;
|
|
35
|
+
|
|
36
|
+
// 2. Platform-specific npm package (installed via optionalDependencies)
|
|
37
|
+
const pkg = `csvql-query-${process.platform}-${process.arch}`;
|
|
38
|
+
try {
|
|
39
|
+
const pkgDir = path.dirname(require.resolve(`${pkg}/package.json`));
|
|
40
|
+
const bin = path.join(pkgDir, 'csvql.node');
|
|
41
|
+
if (fs.existsSync(bin)) return bin;
|
|
42
|
+
} catch (_) {}
|
|
43
|
+
|
|
44
|
+
// 3. Dev build output (zig build node from repo root)
|
|
45
|
+
const dev = path.join(__dirname, '..', 'zig-out', 'lib', 'csvql.node');
|
|
46
|
+
if (fs.existsSync(dev)) return dev;
|
|
47
|
+
|
|
48
|
+
throw new Error(
|
|
49
|
+
`csvql native module not found for ${process.platform}-${process.arch}.\n` +
|
|
50
|
+
'Install via npm: npm install csvql-query\n' +
|
|
51
|
+
'Build from source: zig build node -Doptimize=ReleaseFast'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const native = require(findNative());
|
|
56
|
+
|
|
57
|
+
// ── Preprocessing (delimiter conversion + comment/blank stripping) ────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Quote-aware delimiter converter for a single line.
|
|
61
|
+
* Splits on `from` respecting double-quoted fields, re-joins with `to`,
|
|
62
|
+
* and re-quotes any output field that contains `to` or a double-quote.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} line
|
|
65
|
+
* @param {string} from source delimiter (e.g. '\t')
|
|
66
|
+
* @param {string} to target delimiter (always ',')
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function convertDelimiter(line, from, to) {
|
|
70
|
+
const fields = [];
|
|
71
|
+
let cur = '', inQuotes = false;
|
|
72
|
+
for (let i = 0; i < line.length; i++) {
|
|
73
|
+
const ch = line[i];
|
|
74
|
+
if (ch === '"') { inQuotes = !inQuotes; cur += ch; }
|
|
75
|
+
else if (!inQuotes && line.startsWith(from, i)) { fields.push(cur); cur = ''; i += from.length - 1; }
|
|
76
|
+
else { cur += ch; }
|
|
77
|
+
}
|
|
78
|
+
fields.push(cur);
|
|
79
|
+
return fields.map(f => {
|
|
80
|
+
if (f.includes(to) || f.includes('"') || f.includes('\n'))
|
|
81
|
+
return '"' + f.replace(/"/g, '""') + '"';
|
|
82
|
+
return f;
|
|
83
|
+
}).join(to);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Write a preprocessed copy of `filePath` to a temp file applying:
|
|
88
|
+
* opts.delimiter — convert this separator to comma
|
|
89
|
+
* opts.comment — skip lines starting with this string
|
|
90
|
+
* opts.skipEmptyLines — skip blank lines
|
|
91
|
+
*
|
|
92
|
+
* Returns the temp file path, or null when no preprocessing is needed.
|
|
93
|
+
* Caller is responsible for deleting the temp file.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} filePath
|
|
96
|
+
* @param {QueryOptions} opts
|
|
97
|
+
* @returns {string|null}
|
|
98
|
+
*/
|
|
99
|
+
function preprocess(filePath, opts) {
|
|
100
|
+
const needsDelim = opts.delimiter && opts.delimiter !== ',';
|
|
101
|
+
const needsComment = typeof opts.comment === 'string';
|
|
102
|
+
const needsSkip = opts.skipEmptyLines === true;
|
|
103
|
+
|
|
104
|
+
if (!needsDelim && !needsComment && !needsSkip) return null;
|
|
105
|
+
|
|
106
|
+
const tmp = path.join(os.tmpdir(), `_csvql_${crypto.randomBytes(6).toString('hex')}.csv`);
|
|
107
|
+
const out = [];
|
|
108
|
+
|
|
109
|
+
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
|
110
|
+
if (needsComment && line.startsWith(opts.comment)) continue;
|
|
111
|
+
if (needsSkip && line.trim() === '') continue;
|
|
112
|
+
out.push(needsDelim ? convertDelimiter(line, opts.delimiter, ',') : line);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fs.writeFileSync(tmp, out.join('\n'));
|
|
116
|
+
return tmp;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Find all file paths referenced in a SQL string (FROM / JOIN clauses)
|
|
121
|
+
* that exist on disk.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} sql
|
|
124
|
+
* @returns {{ match: string, filePath: string }[]}
|
|
125
|
+
*/
|
|
126
|
+
function extractPaths(sql) {
|
|
127
|
+
const found = [];
|
|
128
|
+
const re = /['"]([^'"]+)['"]/g;
|
|
129
|
+
let m;
|
|
130
|
+
while ((m = re.exec(sql)) !== null)
|
|
131
|
+
if (fs.existsSync(m[1])) found.push({ match: m[0], filePath: m[1] });
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Rewrite every file path in `sql` to a preprocessed temp file when needed.
|
|
137
|
+
* Handles JOIN queries with multiple file paths.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} sql
|
|
140
|
+
* @param {QueryOptions} [opts]
|
|
141
|
+
* @returns {{ sql: string, temps: string[] }}
|
|
142
|
+
*/
|
|
143
|
+
function maybePreprocess(sql, opts) {
|
|
144
|
+
if (!opts || !Object.keys(opts).length) return { sql, temps: [] };
|
|
145
|
+
|
|
146
|
+
const temps = [];
|
|
147
|
+
let out = sql;
|
|
148
|
+
|
|
149
|
+
for (const { match, filePath } of extractPaths(sql)) {
|
|
150
|
+
const tmp = preprocess(filePath, opts);
|
|
151
|
+
if (tmp) { out = out.replace(match, `'${tmp}'`); temps.push(tmp); }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { sql: out, temps };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function cleanUp(temps) {
|
|
158
|
+
for (const f of temps) try { fs.unlinkSync(f); } catch (_) {}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @typedef {Object} QueryOptions
|
|
165
|
+
* @property {string} [delimiter] - Source field delimiter when not a comma.
|
|
166
|
+
* Use '\t' for TSV, '|' for pipe-delimited, etc.
|
|
167
|
+
* @property {string} [comment] - Skip lines whose first character(s) match this string.
|
|
168
|
+
* E.g. '#' to strip shell-style comments.
|
|
169
|
+
* @property {boolean} [skipEmptyLines] - Skip blank lines before querying. Default: false.
|
|
170
|
+
* The engine already ignores truly empty rows, but
|
|
171
|
+
* this also removes whitespace-only lines.
|
|
172
|
+
*/
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Run a SQL query against one or more CSV/TSV files and return results
|
|
176
|
+
* as an array of objects.
|
|
177
|
+
*
|
|
178
|
+
* The engine streams through the file without loading all rows into memory,
|
|
179
|
+
* so RAM stays ~5 MB for any file size. Values are typed where possible
|
|
180
|
+
* (numbers come back as JS numbers via JSON.parse).
|
|
181
|
+
*
|
|
182
|
+
* @param {string} sql SQL query. File paths go in FROM/JOIN clauses.
|
|
183
|
+
* E.g. "SELECT * FROM 'data.csv' WHERE age > 30"
|
|
184
|
+
* @param {QueryOptions} [opts]
|
|
185
|
+
* @returns {Record<string, unknown>[]}
|
|
186
|
+
* @throws {Error} on invalid SQL, file not found, or engine error
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* // Basic select
|
|
190
|
+
* csvql.query("SELECT name, city FROM 'employees.csv' LIMIT 10")
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* // WHERE filter
|
|
194
|
+
* csvql.query("SELECT name, salary FROM 'employees.csv' WHERE salary > 100000")
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* // GROUP BY aggregate
|
|
198
|
+
* csvql.query("SELECT city, COUNT(*) as n, AVG(salary) as avg FROM 'employees.csv' GROUP BY city ORDER BY avg DESC")
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* // JOIN two files
|
|
202
|
+
* csvql.query("SELECT e.name, d.name as dept FROM 'employees.csv' e JOIN 'departments.csv' d ON e.dept_id = d.id")
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* // TSV file
|
|
206
|
+
* csvql.query("SELECT * FROM 'data.tsv' WHERE score > 90", { delimiter: '\t' })
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* // Skip comment and blank lines
|
|
210
|
+
* csvql.query("SELECT * FROM 'data.csv'", { comment: '#', skipEmptyLines: true })
|
|
211
|
+
*/
|
|
212
|
+
function query(sql, opts) {
|
|
213
|
+
const { sql: rewritten, temps } = maybePreprocess(sql, opts);
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(native.queryJson(rewritten));
|
|
216
|
+
} finally {
|
|
217
|
+
cleanUp(temps);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Run a SQL query and return results as a CSV string (header row + data rows).
|
|
223
|
+
*
|
|
224
|
+
* Useful for piping results into another CSV consumer or writing to a file
|
|
225
|
+
* without the overhead of JSON serialisation.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} sql
|
|
228
|
+
* @param {QueryOptions} [opts] Same options as {@link query}.
|
|
229
|
+
* @returns {string} CSV text, e.g. "name,salary\nAlice,120000\nBob,80000\n"
|
|
230
|
+
* @throws {Error} on invalid SQL, file not found, or engine error
|
|
231
|
+
*
|
|
232
|
+
* @example
|
|
233
|
+
* const csv = csvql.queryCsv("SELECT city, COUNT(*) FROM 'data.csv' GROUP BY city")
|
|
234
|
+
* fs.writeFileSync('output.csv', csv)
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* // TSV input → CSV output
|
|
238
|
+
* const csv = csvql.queryCsv("SELECT name, score FROM 'results.tsv' ORDER BY score DESC", { delimiter: '\t' })
|
|
239
|
+
*/
|
|
240
|
+
function queryCsv(sql, opts) {
|
|
241
|
+
const { sql: rewritten, temps } = maybePreprocess(sql, opts);
|
|
242
|
+
try {
|
|
243
|
+
return native.queryCsv(rewritten);
|
|
244
|
+
} finally {
|
|
245
|
+
cleanUp(temps);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Simple (no-SQL) API ───────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Translate a simple condition like "salary>100000" or "name=Alice" into SQL.
|
|
253
|
+
* Numbers stay unquoted; anything that isn't a valid number gets single-quoted.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} cond e.g. "salary>100000" or "city=Austin"
|
|
256
|
+
* @returns {string} e.g. "salary > 100000" or "city = 'Austin'"
|
|
257
|
+
*/
|
|
258
|
+
function translateCondition(cond) {
|
|
259
|
+
// Match: column operator value (operators longest-first to avoid = matching >=)
|
|
260
|
+
const m = cond.trim().match(/^(\w+)(>=|<=|!=|>|<|=)(.+)$/);
|
|
261
|
+
if (!m) throw new Error(`Invalid where condition: "${cond}"`);
|
|
262
|
+
const [, col, op, raw] = m;
|
|
263
|
+
const val = raw.trim();
|
|
264
|
+
const quoted = isNaN(Number(val)) ? `'${val}'` : val;
|
|
265
|
+
return `${col} ${op} ${quoted}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Translate a simple where string (may contain AND / OR) into a SQL WHERE clause.
|
|
270
|
+
* e.g. "salary>100000 AND department=Engineering" → "salary > 100000 AND department = 'Engineering'"
|
|
271
|
+
*
|
|
272
|
+
* @param {string} where
|
|
273
|
+
* @returns {string}
|
|
274
|
+
*/
|
|
275
|
+
function translateWhere(where) {
|
|
276
|
+
// Split on AND / OR keeping the keyword, translate each leaf condition.
|
|
277
|
+
return where
|
|
278
|
+
.split(/\b(AND|OR)\b/)
|
|
279
|
+
.map(part => {
|
|
280
|
+
const t = part.trim();
|
|
281
|
+
if (t === 'AND' || t === 'OR') return t;
|
|
282
|
+
return translateCondition(t);
|
|
283
|
+
})
|
|
284
|
+
.join(' ');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Translate a simple orderBy string into a SQL ORDER BY clause.
|
|
289
|
+
* e.g. "salary:desc" → "salary DESC" | "name" → "name ASC"
|
|
290
|
+
*
|
|
291
|
+
* @param {string} orderBy
|
|
292
|
+
* @returns {string}
|
|
293
|
+
*/
|
|
294
|
+
function translateOrderBy(orderBy) {
|
|
295
|
+
const [col, dir = 'asc'] = orderBy.split(':');
|
|
296
|
+
return `${col.trim()} ${dir.trim().toUpperCase()}`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Build a SQL string from simple find() options.
|
|
301
|
+
*
|
|
302
|
+
* @param {string} file
|
|
303
|
+
* @param {FindOptions} opts
|
|
304
|
+
* @returns {string}
|
|
305
|
+
*/
|
|
306
|
+
function buildSql(file, opts) {
|
|
307
|
+
const cols = opts.columns
|
|
308
|
+
? (Array.isArray(opts.columns) ? opts.columns.join(', ') : opts.columns)
|
|
309
|
+
: '*';
|
|
310
|
+
|
|
311
|
+
let sql = `SELECT ${cols} FROM '${file}'`;
|
|
312
|
+
if (opts.where) sql += ` WHERE ${translateWhere(opts.where)}`;
|
|
313
|
+
if (opts.orderBy) sql += ` ORDER BY ${translateOrderBy(opts.orderBy)}`;
|
|
314
|
+
if (opts.limit) sql += ` LIMIT ${opts.limit}`;
|
|
315
|
+
return sql;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* @typedef {Object} FindOptions
|
|
320
|
+
* @property {string|string[]} [columns] - Columns to return. Comma-separated string or
|
|
321
|
+
* array. Default: all columns.
|
|
322
|
+
* @property {string} [where] - Filter condition in simple syntax — no SQL needed.
|
|
323
|
+
* Operators: = != > >= < <=
|
|
324
|
+
* Combine with AND / OR.
|
|
325
|
+
* Values are auto-quoted (numbers stay unquoted).
|
|
326
|
+
* E.g. 'salary>100000 AND department=Engineering'
|
|
327
|
+
* @property {number} [limit] - Maximum rows to return. Default: all rows.
|
|
328
|
+
* @property {string} [orderBy] - Sort column and direction: 'column:asc' or
|
|
329
|
+
* 'column:desc'. Direction defaults to asc.
|
|
330
|
+
* E.g. 'salary:desc'
|
|
331
|
+
* @property {string} [delimiter] - Source delimiter when not comma. E.g. '\t' for TSV.
|
|
332
|
+
* @property {string} [comment] - Skip lines starting with this string. E.g. '#'.
|
|
333
|
+
* @property {boolean} [skipEmptyLines] - Skip blank lines. Default false.
|
|
334
|
+
*/
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Query a CSV file without writing SQL.
|
|
338
|
+
*
|
|
339
|
+
* Uses the same SIMD engine as `query()` — the simple options are translated
|
|
340
|
+
* into a SQL string internally. No SQL knowledge required.
|
|
341
|
+
*
|
|
342
|
+
* For aggregates (COUNT, SUM, AVG, GROUP BY) use `query()` instead.
|
|
343
|
+
*
|
|
344
|
+
* @param {string} file Path to the CSV/TSV file.
|
|
345
|
+
* @param {FindOptions} [opts]
|
|
346
|
+
* @returns {Record<string, unknown>[]}
|
|
347
|
+
* @throws {Error} on invalid condition, file not found, or engine error
|
|
348
|
+
*
|
|
349
|
+
* @example
|
|
350
|
+
* // All rows
|
|
351
|
+
* csvql.find('employees.csv')
|
|
352
|
+
*
|
|
353
|
+
* @example
|
|
354
|
+
* // Pick columns + filter
|
|
355
|
+
* csvql.find('employees.csv', {
|
|
356
|
+
* columns: ['name', 'city', 'salary'],
|
|
357
|
+
* where: 'salary>100000',
|
|
358
|
+
* })
|
|
359
|
+
*
|
|
360
|
+
* @example
|
|
361
|
+
* // Filter + sort + limit
|
|
362
|
+
* csvql.find('employees.csv', {
|
|
363
|
+
* where: 'department=Engineering AND salary>80000',
|
|
364
|
+
* orderBy: 'salary:desc',
|
|
365
|
+
* limit: 10,
|
|
366
|
+
* })
|
|
367
|
+
*
|
|
368
|
+
* @example
|
|
369
|
+
* // TSV file, no SQL needed
|
|
370
|
+
* csvql.find('scores.tsv', {
|
|
371
|
+
* columns: ['name', 'score'],
|
|
372
|
+
* where: 'score>=90',
|
|
373
|
+
* orderBy: 'score:desc',
|
|
374
|
+
* delimiter: '\t',
|
|
375
|
+
* })
|
|
376
|
+
*/
|
|
377
|
+
function find(file, opts = {}) {
|
|
378
|
+
const sql = buildSql(file, opts);
|
|
379
|
+
return query(sql, opts);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
module.exports = { query, queryCsv, find };
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "csvql-query",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Fast SQL queries on CSV files — Zig/SIMD engine via N-API",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.js",
|
|
9
|
+
"index.d.ts"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "zig build node -Doptimize=ReleaseFast && cp ../zig-out/lib/csvql.node .",
|
|
13
|
+
"test": "node test.js",
|
|
14
|
+
"bench": "node bench.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"csv",
|
|
18
|
+
"sql",
|
|
19
|
+
"query",
|
|
20
|
+
"simd",
|
|
21
|
+
"fast",
|
|
22
|
+
"etl",
|
|
23
|
+
"parser"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/melihbirim/csvql"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"optionalDependencies": {
|
|
34
|
+
"csvql-query-darwin-arm64": "1.0.0",
|
|
35
|
+
"csvql-query-darwin-x64": "1.0.0",
|
|
36
|
+
"csvql-query-linux-x64": "1.0.0",
|
|
37
|
+
"csvql-query-linux-arm64": "1.0.0",
|
|
38
|
+
"csvql-query-win32-x64": "1.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"csv-parse": "^7.0.0",
|
|
42
|
+
"papaparse": "^5.5.4"
|
|
43
|
+
}
|
|
44
|
+
}
|