sql-escaper
Advanced tools
+152
-31
@@ -10,3 +10,2 @@ "use strict"; | ||
| const regex = { | ||
| set: /\bSET\b|\bKEY\s+UPDATE\b/i, | ||
| backtick: /`/g, | ||
@@ -28,8 +27,101 @@ dot: /\./g, | ||
| }; | ||
| const isRecord = (val) => typeof val === 'object' && val !== null && !Array.isArray(val); | ||
| const isDate = (val) => Object.prototype.toString.call(val) === '[object Date]'; | ||
| const hasSqlString = (val) => typeof val === 'object' && | ||
| val !== null && | ||
| 'toSqlString' in val && | ||
| typeof val.toSqlString === 'function'; | ||
| const charCode = { | ||
| singleQuote: 39, | ||
| backslash: 92, | ||
| dash: 45, | ||
| slash: 47, | ||
| asterisk: 42, | ||
| questionMark: 63, | ||
| newline: 10, | ||
| space: 32, | ||
| tab: 9, | ||
| carriageReturn: 13, | ||
| }; | ||
| const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); | ||
| const isWordChar = (code) => (code >= 65 && code <= 90) || | ||
| (code >= 97 && code <= 122) || | ||
| (code >= 48 && code <= 57) || | ||
| code === 95; | ||
| const isWhitespace = (code) => code === charCode.space || | ||
| code === charCode.tab || | ||
| code === charCode.newline || | ||
| code === charCode.carriageReturn; | ||
| const toLower = (code) => code | 32; | ||
| const matchesWord = (sql, position, word, length) => { | ||
| for (let offset = 0; offset < word.length; offset++) | ||
| if (toLower(sql.charCodeAt(position + offset)) !== word.charCodeAt(offset)) | ||
| return false; | ||
| return ((position === 0 || !isWordChar(sql.charCodeAt(position - 1))) && | ||
| (position + word.length >= length || | ||
| !isWordChar(sql.charCodeAt(position + word.length)))); | ||
| }; | ||
| const skipSqlContext = (sql, position) => { | ||
| const currentChar = sql.charCodeAt(position); | ||
| const nextChar = sql.charCodeAt(position + 1); | ||
| if (currentChar === charCode.singleQuote) { | ||
| for (let cursor = position + 1; cursor < sql.length; cursor++) { | ||
| if (sql.charCodeAt(cursor) === charCode.backslash) | ||
| cursor++; | ||
| else if (sql.charCodeAt(cursor) === charCode.singleQuote) | ||
| return cursor + 1; | ||
| } | ||
| return sql.length; | ||
| } | ||
| if (currentChar === charCode.dash && nextChar === charCode.dash) { | ||
| const lineBreak = sql.indexOf('\n', position + 2); | ||
| return lineBreak === -1 ? sql.length : lineBreak + 1; | ||
| } | ||
| if (currentChar === charCode.slash && nextChar === charCode.asterisk) { | ||
| const commentEnd = sql.indexOf('*/', position + 2); | ||
| return commentEnd === -1 ? sql.length : commentEnd + 2; | ||
| } | ||
| return -1; | ||
| }; | ||
| const findNextPlaceholder = (sql, start) => { | ||
| const sqlLength = sql.length; | ||
| for (let position = start; position < sqlLength; position++) { | ||
| const code = sql.charCodeAt(position); | ||
| if (code === charCode.questionMark) | ||
| return position; | ||
| if (code === charCode.singleQuote || | ||
| code === charCode.dash || | ||
| code === charCode.slash) { | ||
| const contextEnd = skipSqlContext(sql, position); | ||
| if (contextEnd !== -1) | ||
| position = contextEnd - 1; | ||
| } | ||
| } | ||
| return -1; | ||
| }; | ||
| const findSetKeyword = (sql) => { | ||
| const length = sql.length; | ||
| for (let position = 0; position < length; position++) { | ||
| const code = sql.charCodeAt(position); | ||
| const lower = code | 32; | ||
| if (code === charCode.singleQuote || | ||
| code === charCode.dash || | ||
| code === charCode.slash) { | ||
| const contextEnd = skipSqlContext(sql, position); | ||
| if (contextEnd !== -1) { | ||
| position = contextEnd - 1; | ||
| continue; | ||
| } | ||
| } | ||
| if (lower === 115 && matchesWord(sql, position, 'set', length)) | ||
| return position; | ||
| if (lower === 107 && matchesWord(sql, position, 'key', length)) { | ||
| let cursor = position + 3; | ||
| while (cursor < length && isWhitespace(sql.charCodeAt(cursor))) | ||
| cursor++; | ||
| if (matchesWord(sql, cursor, 'update', length)) | ||
| return position; | ||
| } | ||
| } | ||
| return -1; | ||
| }; | ||
| const isDate = (value) => Object.prototype.toString.call(value) === '[object Date]'; | ||
| const hasSqlString = (value) => typeof value === 'object' && | ||
| value !== null && | ||
| 'toSqlString' in value && | ||
| typeof value.toSqlString === 'function'; | ||
| const escapeString = (value) => { | ||
@@ -41,4 +133,4 @@ regex.escapeChars.lastIndex = 0; | ||
| for (match = regex.escapeChars.exec(value); match !== null; match = regex.escapeChars.exec(value)) { | ||
| escapedValue += | ||
| value.slice(chunkIndex, match.index) + CHARS_ESCAPE_MAP[match[0]]; | ||
| escapedValue += value.slice(chunkIndex, match.index); | ||
| escapedValue += CHARS_ESCAPE_MAP[match[0]]; | ||
| chunkIndex = regex.escapeChars.lastIndex; | ||
@@ -52,3 +144,11 @@ } | ||
| }; | ||
| const zeroPad = (number, length) => String(number).padStart(length, '0'); | ||
| const pad2 = (value) => (value < 10 ? '0' + value : '' + value); | ||
| const pad3 = (value) => value < 10 ? '00' + value : value < 100 ? '0' + value : '' + value; | ||
| const pad4 = (value) => value < 10 | ||
| ? '000' + value | ||
| : value < 100 | ||
| ? '00' + value | ||
| : value < 1000 | ||
| ? '0' + value | ||
| : '' + value; | ||
| const convertTimezone = (tz) => { | ||
@@ -66,4 +166,3 @@ if (tz === 'Z') | ||
| const dateToString = (date, timezone) => { | ||
| const adjustedDate = new Date(date); | ||
| if (Number.isNaN(adjustedDate.getTime())) | ||
| if (Number.isNaN(date.getTime())) | ||
| return 'NULL'; | ||
@@ -78,15 +177,16 @@ let year; | ||
| if (timezone === 'local') { | ||
| year = adjustedDate.getFullYear(); | ||
| month = adjustedDate.getMonth() + 1; | ||
| day = adjustedDate.getDate(); | ||
| hour = adjustedDate.getHours(); | ||
| minute = adjustedDate.getMinutes(); | ||
| second = adjustedDate.getSeconds(); | ||
| millisecond = adjustedDate.getMilliseconds(); | ||
| year = date.getFullYear(); | ||
| month = date.getMonth() + 1; | ||
| day = date.getDate(); | ||
| hour = date.getHours(); | ||
| minute = date.getMinutes(); | ||
| second = date.getSeconds(); | ||
| millisecond = date.getMilliseconds(); | ||
| } | ||
| else { | ||
| const timezoneOffsetMinutes = convertTimezone(timezone); | ||
| if (timezoneOffsetMinutes !== false && timezoneOffsetMinutes !== 0) { | ||
| adjustedDate.setTime(adjustedDate.getTime() + timezoneOffsetMinutes * 60000); | ||
| } | ||
| let time = date.getTime(); | ||
| if (timezoneOffsetMinutes !== false && timezoneOffsetMinutes !== 0) | ||
| time += timezoneOffsetMinutes * 60000; | ||
| const adjustedDate = new Date(time); | ||
| year = adjustedDate.getUTCFullYear(); | ||
@@ -101,4 +201,15 @@ month = adjustedDate.getUTCMonth() + 1; | ||
| // YYYY-MM-DD HH:mm:ss.mmm | ||
| const formattedDateTime = `${zeroPad(year, 4)}-${zeroPad(month, 2)}-${zeroPad(day, 2)} ${zeroPad(hour, 2)}:${zeroPad(minute, 2)}:${zeroPad(second, 2)}.${zeroPad(millisecond, 3)}`; | ||
| return escapeString(formattedDateTime); | ||
| return escapeString(pad4(year) + | ||
| '-' + | ||
| pad2(month) + | ||
| '-' + | ||
| pad2(day) + | ||
| ' ' + | ||
| pad2(hour) + | ||
| ':' + | ||
| pad2(minute) + | ||
| ':' + | ||
| pad2(second) + | ||
| '.' + | ||
| pad3(millisecond)); | ||
| }; | ||
@@ -128,8 +239,17 @@ exports.dateToString = dateToString; | ||
| const objectToValues = (object, timezone) => { | ||
| const keys = Object.keys(object); | ||
| const keysLength = keys.length; | ||
| if (keysLength === 0) | ||
| return ''; | ||
| let sql = ''; | ||
| for (const key in object) { | ||
| for (let i = 0; i < keysLength; i++) { | ||
| const key = keys[i]; | ||
| const value = object[key]; | ||
| if (typeof value === 'function') | ||
| continue; | ||
| sql += `${(sql.length === 0 ? '' : ', ') + (0, exports.escapeId)(key)} = ${(0, exports.escape)(value, true, timezone)}`; | ||
| if (sql.length > 0) | ||
| sql += ', '; | ||
| sql += (0, exports.escapeId)(key); | ||
| sql += ' = '; | ||
| sql += (0, exports.escape)(value, true, timezone); | ||
| } | ||
@@ -193,3 +313,3 @@ return sql; | ||
| let valuesIndex = 0; | ||
| let placeholderPosition = sql.indexOf('?'); | ||
| let placeholderPosition = findNextPlaceholder(sql, 0); | ||
| while (valuesIndex < length && placeholderPosition !== -1) { | ||
@@ -204,3 +324,3 @@ // Count consecutive question marks to detect ? vs ?? vs ???+ | ||
| if (placeholderLength > 2) { | ||
| placeholderPosition = sql.indexOf('?', placeholderEnd); | ||
| placeholderPosition = findNextPlaceholder(sql, placeholderEnd); | ||
| continue; | ||
@@ -217,3 +337,3 @@ } | ||
| if (setIndex === -2) | ||
| setIndex = sql.search(regex.set); | ||
| setIndex = findSetKeyword(sql); | ||
| if (setIndex !== -1 && | ||
@@ -232,6 +352,7 @@ setIndex < placeholderPosition && | ||
| escapedValue = (0, exports.escape)(currentValue, stringifyObjects, timezone); | ||
| result += sql.slice(chunkIndex, placeholderPosition) + escapedValue; | ||
| result += sql.slice(chunkIndex, placeholderPosition); | ||
| result += escapedValue; | ||
| chunkIndex = placeholderEnd; | ||
| valuesIndex++; | ||
| placeholderPosition = sql.indexOf('?', placeholderEnd); | ||
| placeholderPosition = findNextPlaceholder(sql, placeholderEnd); | ||
| } | ||
@@ -238,0 +359,0 @@ if (chunkIndex === 0) |
+114
-39
| import { Buffer } from "node:buffer"; | ||
| const regex = { | ||
| set: /\bSET\b|\bKEY\s+UPDATE\b/i, | ||
| backtick: /`/g, | ||
@@ -20,5 +19,81 @@ dot: /\./g, | ||
| }; | ||
| const isRecord = (val) => typeof val === "object" && val !== null && !Array.isArray(val); | ||
| const isDate = (val) => Object.prototype.toString.call(val) === "[object Date]"; | ||
| const hasSqlString = (val) => typeof val === "object" && val !== null && "toSqlString" in val && typeof val.toSqlString === "function"; | ||
| const charCode = { | ||
| singleQuote: 39, | ||
| backslash: 92, | ||
| dash: 45, | ||
| slash: 47, | ||
| asterisk: 42, | ||
| questionMark: 63, | ||
| newline: 10, | ||
| space: 32, | ||
| tab: 9, | ||
| carriageReturn: 13 | ||
| }; | ||
| const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value); | ||
| const isWordChar = (code) => code >= 65 && code <= 90 || code >= 97 && code <= 122 || code >= 48 && code <= 57 || code === 95; | ||
| const isWhitespace = (code) => code === charCode.space || code === charCode.tab || code === charCode.newline || code === charCode.carriageReturn; | ||
| const toLower = (code) => code | 32; | ||
| const matchesWord = (sql, position, word, length) => { | ||
| for (let offset = 0; offset < word.length; offset++) | ||
| if (toLower(sql.charCodeAt(position + offset)) !== word.charCodeAt(offset)) | ||
| return false; | ||
| return (position === 0 || !isWordChar(sql.charCodeAt(position - 1))) && (position + word.length >= length || !isWordChar(sql.charCodeAt(position + word.length))); | ||
| }; | ||
| const skipSqlContext = (sql, position) => { | ||
| const currentChar = sql.charCodeAt(position); | ||
| const nextChar = sql.charCodeAt(position + 1); | ||
| if (currentChar === charCode.singleQuote) { | ||
| for (let cursor = position + 1; cursor < sql.length; cursor++) { | ||
| if (sql.charCodeAt(cursor) === charCode.backslash) cursor++; | ||
| else if (sql.charCodeAt(cursor) === charCode.singleQuote) | ||
| return cursor + 1; | ||
| } | ||
| return sql.length; | ||
| } | ||
| if (currentChar === charCode.dash && nextChar === charCode.dash) { | ||
| const lineBreak = sql.indexOf("\n", position + 2); | ||
| return lineBreak === -1 ? sql.length : lineBreak + 1; | ||
| } | ||
| if (currentChar === charCode.slash && nextChar === charCode.asterisk) { | ||
| const commentEnd = sql.indexOf("*/", position + 2); | ||
| return commentEnd === -1 ? sql.length : commentEnd + 2; | ||
| } | ||
| return -1; | ||
| }; | ||
| const findNextPlaceholder = (sql, start) => { | ||
| const sqlLength = sql.length; | ||
| for (let position = start; position < sqlLength; position++) { | ||
| const code = sql.charCodeAt(position); | ||
| if (code === charCode.questionMark) return position; | ||
| if (code === charCode.singleQuote || code === charCode.dash || code === charCode.slash) { | ||
| const contextEnd = skipSqlContext(sql, position); | ||
| if (contextEnd !== -1) position = contextEnd - 1; | ||
| } | ||
| } | ||
| return -1; | ||
| }; | ||
| const findSetKeyword = (sql) => { | ||
| const length = sql.length; | ||
| for (let position = 0; position < length; position++) { | ||
| const code = sql.charCodeAt(position); | ||
| const lower = code | 32; | ||
| if (code === charCode.singleQuote || code === charCode.dash || code === charCode.slash) { | ||
| const contextEnd = skipSqlContext(sql, position); | ||
| if (contextEnd !== -1) { | ||
| position = contextEnd - 1; | ||
| continue; | ||
| } | ||
| } | ||
| if (lower === 115 && matchesWord(sql, position, "set", length)) | ||
| return position; | ||
| if (lower === 107 && matchesWord(sql, position, "key", length)) { | ||
| let cursor = position + 3; | ||
| while (cursor < length && isWhitespace(sql.charCodeAt(cursor))) cursor++; | ||
| if (matchesWord(sql, cursor, "update", length)) return position; | ||
| } | ||
| } | ||
| return -1; | ||
| }; | ||
| const isDate = (value) => Object.prototype.toString.call(value) === "[object Date]"; | ||
| const hasSqlString = (value) => typeof value === "object" && value !== null && "toSqlString" in value && typeof value.toSqlString === "function"; | ||
| const escapeString = (value) => { | ||
@@ -30,3 +105,4 @@ regex.escapeChars.lastIndex = 0; | ||
| for (match = regex.escapeChars.exec(value); match !== null; match = regex.escapeChars.exec(value)) { | ||
| escapedValue += value.slice(chunkIndex, match.index) + CHARS_ESCAPE_MAP[match[0]]; | ||
| escapedValue += value.slice(chunkIndex, match.index); | ||
| escapedValue += CHARS_ESCAPE_MAP[match[0]]; | ||
| chunkIndex = regex.escapeChars.lastIndex; | ||
@@ -39,3 +115,5 @@ } | ||
| }; | ||
| const zeroPad = (number, length) => String(number).padStart(length, "0"); | ||
| const pad2 = (value) => value < 10 ? "0" + value : "" + value; | ||
| const pad3 = (value) => value < 10 ? "00" + value : value < 100 ? "0" + value : "" + value; | ||
| const pad4 = (value) => value < 10 ? "000" + value : value < 100 ? "00" + value : value < 1e3 ? "0" + value : "" + value; | ||
| const convertTimezone = (tz) => { | ||
@@ -49,4 +127,3 @@ if (tz === "Z") return 0; | ||
| const dateToString = (date, timezone) => { | ||
| const adjustedDate = new Date(date); | ||
| if (Number.isNaN(adjustedDate.getTime())) return "NULL"; | ||
| if (Number.isNaN(date.getTime())) return "NULL"; | ||
| let year; | ||
@@ -60,16 +137,15 @@ let month; | ||
| if (timezone === "local") { | ||
| year = adjustedDate.getFullYear(); | ||
| month = adjustedDate.getMonth() + 1; | ||
| day = adjustedDate.getDate(); | ||
| hour = adjustedDate.getHours(); | ||
| minute = adjustedDate.getMinutes(); | ||
| second = adjustedDate.getSeconds(); | ||
| millisecond = adjustedDate.getMilliseconds(); | ||
| year = date.getFullYear(); | ||
| month = date.getMonth() + 1; | ||
| day = date.getDate(); | ||
| hour = date.getHours(); | ||
| minute = date.getMinutes(); | ||
| second = date.getSeconds(); | ||
| millisecond = date.getMilliseconds(); | ||
| } else { | ||
| const timezoneOffsetMinutes = convertTimezone(timezone); | ||
| if (timezoneOffsetMinutes !== false && timezoneOffsetMinutes !== 0) { | ||
| adjustedDate.setTime( | ||
| adjustedDate.getTime() + timezoneOffsetMinutes * 6e4 | ||
| ); | ||
| } | ||
| let time = date.getTime(); | ||
| if (timezoneOffsetMinutes !== false && timezoneOffsetMinutes !== 0) | ||
| time += timezoneOffsetMinutes * 6e4; | ||
| const adjustedDate = new Date(time); | ||
| year = adjustedDate.getUTCFullYear(); | ||
@@ -83,10 +159,5 @@ month = adjustedDate.getUTCMonth() + 1; | ||
| } | ||
| const formattedDateTime = `${zeroPad(year, 4)}-${zeroPad(month, 2)}-${zeroPad( | ||
| day, | ||
| 2 | ||
| )} ${zeroPad(hour, 2)}:${zeroPad(minute, 2)}:${zeroPad(second, 2)}.${zeroPad( | ||
| millisecond, | ||
| 3 | ||
| )}`; | ||
| return escapeString(formattedDateTime); | ||
| return escapeString( | ||
| pad4(year) + "-" + pad2(month) + "-" + pad2(day) + " " + pad2(hour) + ":" + pad2(minute) + ":" + pad2(second) + "." + pad3(millisecond) | ||
| ); | ||
| }; | ||
@@ -111,11 +182,14 @@ const escapeId = (value, forbidQualified) => { | ||
| const objectToValues = (object, timezone) => { | ||
| const keys = Object.keys(object); | ||
| const keysLength = keys.length; | ||
| if (keysLength === 0) return ""; | ||
| let sql = ""; | ||
| for (const key in object) { | ||
| for (let i = 0; i < keysLength; i++) { | ||
| const key = keys[i]; | ||
| const value = object[key]; | ||
| if (typeof value === "function") continue; | ||
| sql += `${(sql.length === 0 ? "" : ", ") + escapeId(key)} = ${escape( | ||
| value, | ||
| true, | ||
| timezone | ||
| )}`; | ||
| if (sql.length > 0) sql += ", "; | ||
| sql += escapeId(key); | ||
| sql += " = "; | ||
| sql += escape(value, true, timezone); | ||
| } | ||
@@ -166,3 +240,3 @@ return sql; | ||
| let valuesIndex = 0; | ||
| let placeholderPosition = sql.indexOf("?"); | ||
| let placeholderPosition = findNextPlaceholder(sql, 0); | ||
| while (valuesIndex < length && placeholderPosition !== -1) { | ||
@@ -175,3 +249,3 @@ let placeholderEnd = placeholderPosition + 1; | ||
| if (placeholderLength > 2) { | ||
| placeholderPosition = sql.indexOf("?", placeholderEnd); | ||
| placeholderPosition = findNextPlaceholder(sql, placeholderEnd); | ||
| continue; | ||
@@ -182,3 +256,3 @@ } | ||
| else if (typeof currentValue === "object" && currentValue !== null && !stringifyObjects) { | ||
| if (setIndex === -2) setIndex = sql.search(regex.set); | ||
| if (setIndex === -2) setIndex = findSetKeyword(sql); | ||
| if (setIndex !== -1 && setIndex < placeholderPosition && !hasSqlString(currentValue) && !Array.isArray(currentValue) && !Buffer.isBuffer(currentValue) && !isDate(currentValue) && isRecord(currentValue)) | ||
@@ -188,6 +262,7 @@ escapedValue = objectToValues(currentValue, timezone); | ||
| } else escapedValue = escape(currentValue, stringifyObjects, timezone); | ||
| result += sql.slice(chunkIndex, placeholderPosition) + escapedValue; | ||
| result += sql.slice(chunkIndex, placeholderPosition); | ||
| result += escapedValue; | ||
| chunkIndex = placeholderEnd; | ||
| valuesIndex++; | ||
| placeholderPosition = sql.indexOf("?", placeholderEnd); | ||
| placeholderPosition = findNextPlaceholder(sql, placeholderEnd); | ||
| } | ||
@@ -194,0 +269,0 @@ if (chunkIndex === 0) return sql; |
+1
-1
| { | ||
| "name": "sql-escaper", | ||
| "version": "1.0.0", | ||
| "version": "1.1.0", | ||
| "description": "🛡️ Faster SQL escape and format for JavaScript (Node.js, Bun, and Deno).", | ||
@@ -5,0 +5,0 @@ "main": "./lib/index.js", |
+6
-6
@@ -317,8 +317,8 @@ <h1 align="center">SQL Escaper</h1> | ||
| | ---------------------------------------- | ---------: | ----------: | ---------------: | | ||
| | Select 100 values | 460.9 ms | 242.2 ms | **1.90x faster** | | ||
| | Insert 100 values | 468.6 ms | 242.5 ms | **1.93x faster** | | ||
| | SET with 100 values | 484.2 ms | 257.0 ms | **1.88x faster** | | ||
| | SET with 100 objects | 671.6 ms | 283.2 ms | **2.37x faster** | | ||
| | ON DUPLICATE KEY UPDATE with 100 values | 894.0 ms | 459.8 ms | **1.94x faster** | | ||
| | ON DUPLICATE KEY UPDATE with 100 objects | 1,092.0 ms | 485.7 ms | **2.25x faster** | | ||
| | Select 100 values | 450.3 ms | 178.1 ms | **2.53x faster** | | ||
| | Insert 100 values | 453.2 ms | 198.0 ms | **2.29x faster** | | ||
| | SET with 100 values | 471.3 ms | 213.8 ms | **2.20x faster** | | ||
| | SET with 100 objects | 656.9 ms | 249.6 ms | **2.63x faster** | | ||
| | ON DUPLICATE KEY UPDATE with 100 values | 873.2 ms | 397.8 ms | **2.20x faster** | | ||
| | ON DUPLICATE KEY UPDATE with 100 objects | 1,076.0 ms | 442.6 ms | **2.43x faster** | | ||
@@ -325,0 +325,0 @@ - See detailed results and how the benchmarks are run in the [**benchmark**](https://github.com/wellwelwel/sql-escaper/tree/main/benchmark) directory. |
40610
21.06%661
42.15%