sql-escaper
Advanced tools
+1
-1
@@ -10,3 +10,3 @@ /** | ||
| export declare const escapeId: (value: SqlValue, forbidQualified?: boolean) => string; | ||
| export declare const objectToValues: (object: Record<string, SqlValue>, timezone?: Timezone) => string; | ||
| export declare const objectToValues: (object: Record<string, SqlValue> | Map<string, SqlValue>, timezone?: Timezone) => string; | ||
| export declare const bufferToString: (buffer: Buffer) => string; | ||
@@ -13,0 +13,0 @@ export declare const arrayToList: (array: SqlValue[], timezone?: Timezone) => string; |
+240
-83
@@ -9,2 +9,20 @@ "use strict"; | ||
| const node_buffer_1 = require("node:buffer"); | ||
| const CONTEXT_TRIGGER = new Uint8Array(128); | ||
| const SET_CLAUSE_TERMINATORS_BY_FIRST = {}; | ||
| const SET_CLAUSE_TERMINATORS = [ | ||
| 'where', | ||
| 'order', | ||
| 'group', | ||
| 'having', | ||
| 'limit', | ||
| 'union', | ||
| 'returning', | ||
| 'into', | ||
| 'for', | ||
| 'lock', | ||
| 'offset', | ||
| 'window', | ||
| 'procedure', | ||
| 'on', | ||
| ]; | ||
| const regex = { | ||
@@ -16,13 +34,2 @@ backtick: /`/g, | ||
| }; | ||
| const CHARS_ESCAPE_MAP = { | ||
| '\0': '\\0', | ||
| '\b': '\\b', | ||
| '\t': '\\t', | ||
| '\n': '\\n', | ||
| '\r': '\\r', | ||
| '\x1a': '\\Z', | ||
| '"': '\\"', | ||
| "'": "\\'", | ||
| '\\': '\\\\', | ||
| }; | ||
| const charCode = { | ||
@@ -35,3 +42,9 @@ singleQuote: 39, | ||
| asterisk: 42, | ||
| exclamation: 33, | ||
| plus: 43, | ||
| questionMark: 63, | ||
| comma: 44, | ||
| openParen: 40, | ||
| closeParen: 41, | ||
| semicolon: 59, | ||
| newline: 10, | ||
@@ -42,3 +55,22 @@ space: 32, | ||
| }; | ||
| const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); | ||
| // Chars that open a string, identifier, or comment | ||
| CONTEXT_TRIGGER[charCode.singleQuote] = 1; | ||
| CONTEXT_TRIGGER[charCode.backtick] = 1; | ||
| CONTEXT_TRIGGER[charCode.dash] = 1; | ||
| CONTEXT_TRIGGER[charCode.slash] = 1; | ||
| // Bucket terminators by their first character | ||
| for (const word of SET_CLAUSE_TERMINATORS) { | ||
| const first = word.charCodeAt(0); | ||
| const bucket = SET_CLAUSE_TERMINATORS_BY_FIRST[first]; | ||
| if (bucket) | ||
| bucket.push(word); | ||
| else | ||
| SET_CLAUSE_TERMINATORS_BY_FIRST[first] = [word]; | ||
| } | ||
| const hasOwnProperty = Object.prototype.hasOwnProperty; | ||
| const isRecord = (value) => typeof value === 'object' && | ||
| value !== null && | ||
| !Array.isArray(value) && | ||
| !(value instanceof Set) && | ||
| !(value instanceof Map); | ||
| const isWordChar = (code) => (code >= 65 && code <= 90) || | ||
@@ -52,23 +84,11 @@ (code >= 97 && code <= 122) || | ||
| code === charCode.carriageReturn; | ||
| const hasOnlyWhitespaceBetween = (sql, start, end) => { | ||
| if (start >= end) | ||
| return true; | ||
| for (let i = start; i < end; i++) { | ||
| const code = sql.charCodeAt(i); | ||
| if (code !== charCode.space && | ||
| code !== charCode.tab && | ||
| code !== charCode.newline && | ||
| code !== charCode.carriageReturn) | ||
| return false; | ||
| } | ||
| return true; | ||
| }; | ||
| const toLower = (code) => code | 32; | ||
| const matchesWord = (sql, position, word, length) => { | ||
| for (let offset = 0; offset < word.length; offset++) | ||
| const wordLength = word.length; | ||
| for (let offset = 0; offset < wordLength; 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)))); | ||
| (position + wordLength >= length || | ||
| !isWordChar(sql.charCodeAt(position + wordLength)))); | ||
| }; | ||
@@ -101,6 +121,13 @@ const skipSqlContext = (sql, position) => { | ||
| if (currentChar === charCode.dash && nextChar === charCode.dash) { | ||
| const lineBreak = sql.indexOf('\n', position + 2); | ||
| return lineBreak === -1 ? sql.length : lineBreak + 1; | ||
| const afterDash = sql.charCodeAt(position + 2); | ||
| if (Number.isNaN(afterDash) || afterDash <= charCode.space) { | ||
| const lineBreak = sql.indexOf('\n', position + 2); | ||
| return lineBreak === -1 ? sql.length : lineBreak + 1; | ||
| } | ||
| return -1; | ||
| } | ||
| if (currentChar === charCode.slash && nextChar === charCode.asterisk) { | ||
| const markerChar = sql.charCodeAt(position + 2); | ||
| if (markerChar === charCode.exclamation || markerChar === charCode.plus) | ||
| return -1; | ||
| const commentEnd = sql.indexOf('*/', position + 2); | ||
@@ -117,6 +144,3 @@ return commentEnd === -1 ? sql.length : commentEnd + 2; | ||
| return position; | ||
| if (code === charCode.singleQuote || | ||
| code === charCode.backtick || | ||
| code === charCode.dash || | ||
| code === charCode.slash) { | ||
| if (code < 128 && CONTEXT_TRIGGER[code]) { | ||
| const contextEnd = skipSqlContext(sql, position); | ||
@@ -129,2 +153,68 @@ if (contextEnd !== -1) | ||
| }; | ||
| const isInSetAssignmentList = (sql, setEnd, placeholderPosition) => { | ||
| const length = sql.length; | ||
| let depth = 0; | ||
| let sawContent = false; | ||
| let lastWasComma = false; | ||
| for (let i = setEnd; i < placeholderPosition;) { | ||
| const code = sql.charCodeAt(i); | ||
| if (code < 128 && CONTEXT_TRIGGER[code]) { | ||
| const contextEnd = skipSqlContext(sql, i); | ||
| if (contextEnd !== -1) { | ||
| i = contextEnd; | ||
| sawContent = true; | ||
| lastWasComma = false; | ||
| continue; | ||
| } | ||
| } | ||
| if (isWhitespace(code)) { | ||
| i++; | ||
| continue; | ||
| } | ||
| if (code === charCode.openParen) { | ||
| depth++; | ||
| sawContent = true; | ||
| lastWasComma = false; | ||
| i++; | ||
| continue; | ||
| } | ||
| if (code === charCode.closeParen) { | ||
| if (--depth < 0) | ||
| return false; | ||
| sawContent = true; | ||
| lastWasComma = false; | ||
| i++; | ||
| continue; | ||
| } | ||
| if (isWordChar(code)) { | ||
| if (depth === 0 && !(code >= 48 && code <= 57)) { | ||
| const bucket = SET_CLAUSE_TERMINATORS_BY_FIRST[code | 32]; | ||
| if (bucket) | ||
| for (let t = 0; t < bucket.length; t++) | ||
| if (matchesWord(sql, i, bucket[t], length)) | ||
| return false; | ||
| } | ||
| do { | ||
| i++; | ||
| } while (i < placeholderPosition && isWordChar(sql.charCodeAt(i))); | ||
| sawContent = true; | ||
| lastWasComma = false; | ||
| continue; | ||
| } | ||
| if (depth === 0) { | ||
| if (code === charCode.semicolon) | ||
| return false; | ||
| if (code === charCode.comma) { | ||
| lastWasComma = true; | ||
| sawContent = true; | ||
| i++; | ||
| continue; | ||
| } | ||
| } | ||
| sawContent = true; | ||
| lastWasComma = false; | ||
| i++; | ||
| } | ||
| return depth === 0 && (!sawContent || lastWasComma); | ||
| }; | ||
| const findSetKeyword = (sql, startFrom = 0) => { | ||
@@ -134,7 +224,3 @@ const length = sql.length; | ||
| const code = sql.charCodeAt(position); | ||
| const lower = code | 32; | ||
| if (code === charCode.singleQuote || | ||
| code === charCode.backtick || | ||
| code === charCode.dash || | ||
| code === charCode.slash) { | ||
| if (code < 128 && CONTEXT_TRIGGER[code]) { | ||
| const contextEnd = skipSqlContext(sql, position); | ||
@@ -146,2 +232,3 @@ if (contextEnd !== -1) { | ||
| } | ||
| const lower = code | 32; | ||
| if (lower === 115 && matchesWord(sql, position, 'set', length)) | ||
@@ -165,16 +252,47 @@ return position + 3; | ||
| const escapeString = (value) => { | ||
| regex.escapeChars.lastIndex = 0; | ||
| let chunkIndex = 0; | ||
| let escapedValue = ''; | ||
| let match; | ||
| for (match = regex.escapeChars.exec(value); match !== null; match = regex.escapeChars.exec(value)) { | ||
| escapedValue += value.slice(chunkIndex, match.index); | ||
| escapedValue += CHARS_ESCAPE_MAP[match[0]]; | ||
| chunkIndex = regex.escapeChars.lastIndex; | ||
| const escapeChars = regex.escapeChars; | ||
| escapeChars.lastIndex = 0; | ||
| const first = escapeChars.exec(value); | ||
| if (first === null) | ||
| return `'${value}'`; | ||
| const length = value.length; | ||
| let result = "'" + value.slice(0, first.index); | ||
| let chunkStart = first.index; | ||
| for (let i = first.index; i < length; i++) { | ||
| let escaped; | ||
| switch (value.charCodeAt(i)) { | ||
| case 0: | ||
| escaped = '\\0'; | ||
| break; | ||
| case 8: | ||
| escaped = '\\b'; | ||
| break; | ||
| case 9: | ||
| escaped = '\\t'; | ||
| break; | ||
| case 10: | ||
| escaped = '\\n'; | ||
| break; | ||
| case 13: | ||
| escaped = '\\r'; | ||
| break; | ||
| case 26: | ||
| escaped = '\\Z'; | ||
| break; | ||
| case 34: | ||
| escaped = '\\"'; | ||
| break; | ||
| case 39: | ||
| escaped = "\\'"; | ||
| break; | ||
| case 92: | ||
| escaped = '\\\\'; | ||
| break; | ||
| default: | ||
| continue; | ||
| } | ||
| result += value.slice(chunkStart, i) + escaped; | ||
| chunkStart = i + 1; | ||
| } | ||
| if (chunkIndex === 0) | ||
| return `'${value}'`; | ||
| if (chunkIndex < value.length) | ||
| return `'${escapedValue}${value.slice(chunkIndex)}'`; | ||
| return `'${escapedValue}'`; | ||
| return result + value.slice(chunkStart) + "'"; | ||
| }; | ||
@@ -253,9 +371,12 @@ const pad2 = (value) => (value < 10 ? '0' + value : '' + value); | ||
| const length = value.length; | ||
| const parts = new Array(length); | ||
| for (let i = 0; i < length; i++) | ||
| parts[i] = (0, exports.escapeId)(value[i], forbidQualified); | ||
| return parts.join(', '); | ||
| let sql = ''; | ||
| for (let i = 0; i < length; i++) { | ||
| if (i > 0) | ||
| sql += ', '; | ||
| sql += (0, exports.escapeId)(value[i], forbidQualified); | ||
| } | ||
| return sql; | ||
| } | ||
| const identifier = String(value); | ||
| const hasJsonOperator = identifier.indexOf('->') !== -1; | ||
| const hasJsonOperator = !forbidQualified && identifier.indexOf('->') !== -1; | ||
| if (forbidQualified || hasJsonOperator) { | ||
@@ -274,9 +395,18 @@ if (identifier.indexOf('`') === -1) | ||
| const objectToValues = (object, timezone) => { | ||
| const keys = Object.keys(object); | ||
| const keysLength = keys.length; | ||
| if (keysLength === 0) | ||
| return ''; | ||
| let sql = ''; | ||
| for (let i = 0; i < keysLength; i++) { | ||
| const key = keys[i]; | ||
| if (object instanceof Map) { | ||
| for (const [key, value] of object) { | ||
| if (typeof value === 'function') | ||
| continue; | ||
| if (sql.length > 0) | ||
| sql += ', '; | ||
| sql += (0, exports.escapeId)(String(key)); | ||
| sql += ' = '; | ||
| sql += (0, exports.escape)(value, true, timezone); | ||
| } | ||
| return sql; | ||
| } | ||
| for (const key in object) { | ||
| if (!hasOwnProperty.call(object, key)) | ||
| continue; | ||
| const value = object[key]; | ||
@@ -298,11 +428,15 @@ if (typeof value === 'function') | ||
| const length = array.length; | ||
| const parts = new Array(length); | ||
| let sql = ''; | ||
| for (let i = 0; i < length; i++) { | ||
| if (i > 0) | ||
| sql += ', '; | ||
| const value = array[i]; | ||
| if (Array.isArray(value)) | ||
| parts[i] = `(${(0, exports.arrayToList)(value, timezone)})`; | ||
| sql += `(${(0, exports.arrayToList)(value, timezone)})`; | ||
| else if (value instanceof Set) | ||
| sql += `(${(0, exports.arrayToList)(Array.from(value), timezone)})`; | ||
| else | ||
| parts[i] = (0, exports.escape)(value, true, timezone); | ||
| sql += (0, exports.escape)(value, true, timezone); | ||
| } | ||
| return parts.join(', '); | ||
| return sql; | ||
| }; | ||
@@ -324,2 +458,4 @@ exports.arrayToList = arrayToList; | ||
| return (0, exports.arrayToList)(value, timezone); | ||
| if (value instanceof Set) | ||
| return (0, exports.arrayToList)(Array.from(value), timezone); | ||
| if (node_buffer_1.Buffer.isBuffer(value)) | ||
@@ -333,3 +469,3 @@ return (0, exports.bufferToString)(value); | ||
| return escapeString(String(value)); | ||
| if (isRecord(value)) | ||
| if (isRecord(value) || value instanceof Map) | ||
| return (0, exports.objectToValues)(value, timezone); | ||
@@ -351,2 +487,3 @@ return escapeString(String(value)); | ||
| let setIndex = -2; // -2 = not yet computed, -1 = no SET found | ||
| let nextSetIndex = -1; // -1 = no SET after setIndex | ||
| let result = ''; | ||
@@ -370,3 +507,4 @@ let chunkIndex = 0; | ||
| escapedValue = (0, exports.escapeId)(currentValue); | ||
| else if (typeof currentValue === 'number') | ||
| else if (typeof currentValue === 'number' || | ||
| typeof currentValue === 'bigint') | ||
| escapedValue = `${currentValue}`; | ||
@@ -376,16 +514,35 @@ else if (typeof currentValue === 'object' && | ||
| !stringifyObjects) { | ||
| // Lazy: compute SET position only when we first encounter an object | ||
| if (setIndex === -2) | ||
| setIndex = findSetKeyword(sql); | ||
| if (setIndex !== -1 && | ||
| setIndex <= placeholderPosition && | ||
| hasOnlyWhitespaceBetween(sql, setIndex, placeholderPosition) && | ||
| !hasSqlString(currentValue) && | ||
| !Array.isArray(currentValue) && | ||
| !node_buffer_1.Buffer.isBuffer(currentValue) && | ||
| !(currentValue instanceof Uint8Array) && | ||
| !isDate(currentValue) && | ||
| isRecord(currentValue)) { | ||
| escapedValue = (0, exports.objectToValues)(currentValue, timezone); | ||
| setIndex = findSetKeyword(sql, placeholderEnd); | ||
| const expandable = !(Array.isArray(currentValue) || | ||
| currentValue instanceof Uint8Array || | ||
| currentValue instanceof Date || | ||
| hasSqlString(currentValue) || | ||
| isDate(currentValue)) && | ||
| (isRecord(currentValue) || currentValue instanceof Map); | ||
| if (expandable) { | ||
| // A SET assignment follows the keyword (a letter) or a comma continuing the list | ||
| let previous = placeholderPosition - 1; | ||
| while (previous >= chunkIndex && isWhitespace(sql.charCodeAt(previous))) | ||
| previous--; | ||
| const previousChar = previous >= chunkIndex ? toLower(sql.charCodeAt(previous)) : 0; | ||
| if ((previousChar < 97 || previousChar > 122) && | ||
| previousChar !== charCode.comma) | ||
| escapedValue = (0, exports.escape)(currentValue, true, timezone); | ||
| else { | ||
| // Lazy: resolve the first SET and its successor once | ||
| if (setIndex === -2) { | ||
| setIndex = findSetKeyword(sql); | ||
| nextSetIndex = setIndex === -1 ? -1 : findSetKeyword(sql, setIndex); | ||
| } | ||
| // Nearest: advance to the SET closest before this placeholder | ||
| while (nextSetIndex !== -1 && nextSetIndex <= placeholderPosition) { | ||
| setIndex = nextSetIndex; | ||
| nextSetIndex = findSetKeyword(sql, nextSetIndex); | ||
| } | ||
| if (setIndex !== -1 && | ||
| setIndex <= placeholderPosition && | ||
| isInSetAssignmentList(sql, setIndex, placeholderPosition)) | ||
| escapedValue = (0, exports.objectToValues)(currentValue, timezone); | ||
| else | ||
| escapedValue = (0, exports.escape)(currentValue, true, timezone); | ||
| } | ||
| } | ||
@@ -392,0 +549,0 @@ else |
+212
-60
| import { Buffer } from "node:buffer"; | ||
| const CONTEXT_TRIGGER = new Uint8Array(128); | ||
| const SET_CLAUSE_TERMINATORS_BY_FIRST = {}; | ||
| const SET_CLAUSE_TERMINATORS = [ | ||
| "where", | ||
| "order", | ||
| "group", | ||
| "having", | ||
| "limit", | ||
| "union", | ||
| "returning", | ||
| "into", | ||
| "for", | ||
| "lock", | ||
| "offset", | ||
| "window", | ||
| "procedure", | ||
| "on" | ||
| ]; | ||
| const regex = { | ||
@@ -8,13 +26,2 @@ backtick: /`/g, | ||
| }; | ||
| const CHARS_ESCAPE_MAP = { | ||
| "\0": "\\0", | ||
| "\b": "\\b", | ||
| " ": "\\t", | ||
| "\n": "\\n", | ||
| "\r": "\\r", | ||
| "": "\\Z", | ||
| '"': '\\"', | ||
| "'": "\\'", | ||
| "\\": "\\\\" | ||
| }; | ||
| const charCode = { | ||
@@ -27,3 +34,9 @@ singleQuote: 39, | ||
| asterisk: 42, | ||
| exclamation: 33, | ||
| plus: 43, | ||
| questionMark: 63, | ||
| comma: 44, | ||
| openParen: 40, | ||
| closeParen: 41, | ||
| semicolon: 59, | ||
| newline: 10, | ||
@@ -34,20 +47,23 @@ space: 32, | ||
| }; | ||
| const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value); | ||
| CONTEXT_TRIGGER[charCode.singleQuote] = 1; | ||
| CONTEXT_TRIGGER[charCode.backtick] = 1; | ||
| CONTEXT_TRIGGER[charCode.dash] = 1; | ||
| CONTEXT_TRIGGER[charCode.slash] = 1; | ||
| for (const word of SET_CLAUSE_TERMINATORS) { | ||
| const first = word.charCodeAt(0); | ||
| const bucket = SET_CLAUSE_TERMINATORS_BY_FIRST[first]; | ||
| if (bucket) bucket.push(word); | ||
| else SET_CLAUSE_TERMINATORS_BY_FIRST[first] = [word]; | ||
| } | ||
| const hasOwnProperty = Object.prototype.hasOwnProperty; | ||
| const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Set) && !(value instanceof Map); | ||
| 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 hasOnlyWhitespaceBetween = (sql, start, end) => { | ||
| if (start >= end) return true; | ||
| for (let i = start; i < end; i++) { | ||
| const code = sql.charCodeAt(i); | ||
| if (code !== charCode.space && code !== charCode.tab && code !== charCode.newline && code !== charCode.carriageReturn) | ||
| return false; | ||
| } | ||
| return true; | ||
| }; | ||
| const toLower = (code) => code | 32; | ||
| const matchesWord = (sql, position, word, length) => { | ||
| for (let offset = 0; offset < word.length; offset++) | ||
| const wordLength = word.length; | ||
| for (let offset = 0; offset < wordLength; 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))); | ||
| return (position === 0 || !isWordChar(sql.charCodeAt(position - 1))) && (position + wordLength >= length || !isWordChar(sql.charCodeAt(position + wordLength))); | ||
| }; | ||
@@ -78,6 +94,13 @@ const skipSqlContext = (sql, position) => { | ||
| if (currentChar === charCode.dash && nextChar === charCode.dash) { | ||
| const lineBreak = sql.indexOf("\n", position + 2); | ||
| return lineBreak === -1 ? sql.length : lineBreak + 1; | ||
| const afterDash = sql.charCodeAt(position + 2); | ||
| if (Number.isNaN(afterDash) || afterDash <= charCode.space) { | ||
| const lineBreak = sql.indexOf("\n", position + 2); | ||
| return lineBreak === -1 ? sql.length : lineBreak + 1; | ||
| } | ||
| return -1; | ||
| } | ||
| if (currentChar === charCode.slash && nextChar === charCode.asterisk) { | ||
| const markerChar = sql.charCodeAt(position + 2); | ||
| if (markerChar === charCode.exclamation || markerChar === charCode.plus) | ||
| return -1; | ||
| const commentEnd = sql.indexOf("*/", position + 2); | ||
@@ -93,3 +116,3 @@ return commentEnd === -1 ? sql.length : commentEnd + 2; | ||
| if (code === charCode.questionMark) return position; | ||
| if (code === charCode.singleQuote || code === charCode.backtick || code === charCode.dash || code === charCode.slash) { | ||
| if (code < 128 && CONTEXT_TRIGGER[code]) { | ||
| const contextEnd = skipSqlContext(sql, position); | ||
@@ -101,2 +124,66 @@ if (contextEnd !== -1) position = contextEnd - 1; | ||
| }; | ||
| const isInSetAssignmentList = (sql, setEnd, placeholderPosition) => { | ||
| const length = sql.length; | ||
| let depth = 0; | ||
| let sawContent = false; | ||
| let lastWasComma = false; | ||
| for (let i = setEnd; i < placeholderPosition; ) { | ||
| const code = sql.charCodeAt(i); | ||
| if (code < 128 && CONTEXT_TRIGGER[code]) { | ||
| const contextEnd = skipSqlContext(sql, i); | ||
| if (contextEnd !== -1) { | ||
| i = contextEnd; | ||
| sawContent = true; | ||
| lastWasComma = false; | ||
| continue; | ||
| } | ||
| } | ||
| if (isWhitespace(code)) { | ||
| i++; | ||
| continue; | ||
| } | ||
| if (code === charCode.openParen) { | ||
| depth++; | ||
| sawContent = true; | ||
| lastWasComma = false; | ||
| i++; | ||
| continue; | ||
| } | ||
| if (code === charCode.closeParen) { | ||
| if (--depth < 0) return false; | ||
| sawContent = true; | ||
| lastWasComma = false; | ||
| i++; | ||
| continue; | ||
| } | ||
| if (isWordChar(code)) { | ||
| if (depth === 0 && !(code >= 48 && code <= 57)) { | ||
| const bucket = SET_CLAUSE_TERMINATORS_BY_FIRST[code | 32]; | ||
| if (bucket) { | ||
| for (let t = 0; t < bucket.length; t++) | ||
| if (matchesWord(sql, i, bucket[t], length)) return false; | ||
| } | ||
| } | ||
| do { | ||
| i++; | ||
| } while (i < placeholderPosition && isWordChar(sql.charCodeAt(i))); | ||
| sawContent = true; | ||
| lastWasComma = false; | ||
| continue; | ||
| } | ||
| if (depth === 0) { | ||
| if (code === charCode.semicolon) return false; | ||
| if (code === charCode.comma) { | ||
| lastWasComma = true; | ||
| sawContent = true; | ||
| i++; | ||
| continue; | ||
| } | ||
| } | ||
| sawContent = true; | ||
| lastWasComma = false; | ||
| i++; | ||
| } | ||
| return depth === 0 && (!sawContent || lastWasComma); | ||
| }; | ||
| const findSetKeyword = (sql, startFrom = 0) => { | ||
@@ -106,4 +193,3 @@ const length = sql.length; | ||
| const code = sql.charCodeAt(position); | ||
| const lower = code | 32; | ||
| if (code === charCode.singleQuote || code === charCode.backtick || code === charCode.dash || code === charCode.slash) { | ||
| if (code < 128 && CONTEXT_TRIGGER[code]) { | ||
| const contextEnd = skipSqlContext(sql, position); | ||
@@ -115,2 +201,3 @@ if (contextEnd !== -1) { | ||
| } | ||
| const lower = code | 32; | ||
| if (lower === 115 && matchesWord(sql, position, "set", length)) | ||
@@ -129,15 +216,46 @@ return position + 3; | ||
| const escapeString = (value) => { | ||
| regex.escapeChars.lastIndex = 0; | ||
| let chunkIndex = 0; | ||
| let escapedValue = ""; | ||
| let match; | ||
| for (match = regex.escapeChars.exec(value); match !== null; match = regex.escapeChars.exec(value)) { | ||
| escapedValue += value.slice(chunkIndex, match.index); | ||
| escapedValue += CHARS_ESCAPE_MAP[match[0]]; | ||
| chunkIndex = regex.escapeChars.lastIndex; | ||
| const escapeChars = regex.escapeChars; | ||
| escapeChars.lastIndex = 0; | ||
| const first = escapeChars.exec(value); | ||
| if (first === null) return `'${value}'`; | ||
| const length = value.length; | ||
| let result = "'" + value.slice(0, first.index); | ||
| let chunkStart = first.index; | ||
| for (let i = first.index; i < length; i++) { | ||
| let escaped; | ||
| switch (value.charCodeAt(i)) { | ||
| case 0: | ||
| escaped = "\\0"; | ||
| break; | ||
| case 8: | ||
| escaped = "\\b"; | ||
| break; | ||
| case 9: | ||
| escaped = "\\t"; | ||
| break; | ||
| case 10: | ||
| escaped = "\\n"; | ||
| break; | ||
| case 13: | ||
| escaped = "\\r"; | ||
| break; | ||
| case 26: | ||
| escaped = "\\Z"; | ||
| break; | ||
| case 34: | ||
| escaped = '\\"'; | ||
| break; | ||
| case 39: | ||
| escaped = "\\'"; | ||
| break; | ||
| case 92: | ||
| escaped = "\\\\"; | ||
| break; | ||
| default: | ||
| continue; | ||
| } | ||
| result += value.slice(chunkStart, i) + escaped; | ||
| chunkStart = i + 1; | ||
| } | ||
| if (chunkIndex === 0) return `'${value}'`; | ||
| if (chunkIndex < value.length) | ||
| return `'${escapedValue}${value.slice(chunkIndex)}'`; | ||
| return `'${escapedValue}'`; | ||
| return result + value.slice(chunkStart) + "'"; | ||
| }; | ||
@@ -192,9 +310,11 @@ const pad2 = (value) => value < 10 ? "0" + value : "" + value; | ||
| const length = value.length; | ||
| const parts = new Array(length); | ||
| for (let i = 0; i < length; i++) | ||
| parts[i] = escapeId(value[i], forbidQualified); | ||
| return parts.join(", "); | ||
| let sql = ""; | ||
| for (let i = 0; i < length; i++) { | ||
| if (i > 0) sql += ", "; | ||
| sql += escapeId(value[i], forbidQualified); | ||
| } | ||
| return sql; | ||
| } | ||
| const identifier = String(value); | ||
| const hasJsonOperator = identifier.indexOf("->") !== -1; | ||
| const hasJsonOperator = !forbidQualified && identifier.indexOf("->") !== -1; | ||
| if (forbidQualified || hasJsonOperator) { | ||
@@ -209,8 +329,15 @@ if (identifier.indexOf("`") === -1) return `\`${identifier}\``; | ||
| const objectToValues = (object, timezone) => { | ||
| const keys = Object.keys(object); | ||
| const keysLength = keys.length; | ||
| if (keysLength === 0) return ""; | ||
| let sql = ""; | ||
| for (let i = 0; i < keysLength; i++) { | ||
| const key = keys[i]; | ||
| if (object instanceof Map) { | ||
| for (const [key, value] of object) { | ||
| if (typeof value === "function") continue; | ||
| if (sql.length > 0) sql += ", "; | ||
| sql += escapeId(String(key)); | ||
| sql += " = "; | ||
| sql += escape(value, true, timezone); | ||
| } | ||
| return sql; | ||
| } | ||
| for (const key in object) { | ||
| if (!hasOwnProperty.call(object, key)) continue; | ||
| const value = object[key]; | ||
@@ -228,9 +355,12 @@ if (typeof value === "function") continue; | ||
| const length = array.length; | ||
| const parts = new Array(length); | ||
| let sql = ""; | ||
| for (let i = 0; i < length; i++) { | ||
| if (i > 0) sql += ", "; | ||
| const value = array[i]; | ||
| if (Array.isArray(value)) parts[i] = `(${arrayToList(value, timezone)})`; | ||
| else parts[i] = escape(value, true, timezone); | ||
| if (Array.isArray(value)) sql += `(${arrayToList(value, timezone)})`; | ||
| else if (value instanceof Set) | ||
| sql += `(${arrayToList(Array.from(value), timezone)})`; | ||
| else sql += escape(value, true, timezone); | ||
| } | ||
| return parts.join(", "); | ||
| return sql; | ||
| }; | ||
@@ -248,2 +378,4 @@ const escape = (value, stringifyObjects, timezone) => { | ||
| if (Array.isArray(value)) return arrayToList(value, timezone); | ||
| if (value instanceof Set) | ||
| return arrayToList(Array.from(value), timezone); | ||
| if (Buffer.isBuffer(value)) return bufferToString(value); | ||
@@ -255,3 +387,4 @@ if (value instanceof Uint8Array) | ||
| return escapeString(String(value)); | ||
| if (isRecord(value)) return objectToValues(value, timezone); | ||
| if (isRecord(value) || value instanceof Map) | ||
| return objectToValues(value, timezone); | ||
| return escapeString(String(value)); | ||
@@ -270,2 +403,3 @@ } | ||
| let setIndex = -2; | ||
| let nextSetIndex = -1; | ||
| let result = ""; | ||
@@ -286,8 +420,26 @@ let chunkIndex = 0; | ||
| if (placeholderLength === 2) escapedValue = escapeId(currentValue); | ||
| else if (typeof currentValue === "number") escapedValue = `${currentValue}`; | ||
| else if (typeof currentValue === "number" || typeof currentValue === "bigint") | ||
| escapedValue = `${currentValue}`; | ||
| else if (typeof currentValue === "object" && currentValue !== null && !stringifyObjects) { | ||
| if (setIndex === -2) setIndex = findSetKeyword(sql); | ||
| if (setIndex !== -1 && setIndex <= placeholderPosition && hasOnlyWhitespaceBetween(sql, setIndex, placeholderPosition) && !hasSqlString(currentValue) && !Array.isArray(currentValue) && !Buffer.isBuffer(currentValue) && !(currentValue instanceof Uint8Array) && !isDate(currentValue) && isRecord(currentValue)) { | ||
| escapedValue = objectToValues(currentValue, timezone); | ||
| setIndex = findSetKeyword(sql, placeholderEnd); | ||
| const expandable = !(Array.isArray(currentValue) || currentValue instanceof Uint8Array || currentValue instanceof Date || hasSqlString(currentValue) || isDate(currentValue)) && (isRecord(currentValue) || currentValue instanceof Map); | ||
| if (expandable) { | ||
| let previous = placeholderPosition - 1; | ||
| while (previous >= chunkIndex && isWhitespace(sql.charCodeAt(previous))) | ||
| previous--; | ||
| const previousChar = previous >= chunkIndex ? toLower(sql.charCodeAt(previous)) : 0; | ||
| if ((previousChar < 97 || previousChar > 122) && previousChar !== charCode.comma) | ||
| escapedValue = escape(currentValue, true, timezone); | ||
| else { | ||
| if (setIndex === -2) { | ||
| setIndex = findSetKeyword(sql); | ||
| nextSetIndex = setIndex === -1 ? -1 : findSetKeyword(sql, setIndex); | ||
| } | ||
| while (nextSetIndex !== -1 && nextSetIndex <= placeholderPosition) { | ||
| setIndex = nextSetIndex; | ||
| nextSetIndex = findSetKeyword(sql, nextSetIndex); | ||
| } | ||
| if (setIndex !== -1 && setIndex <= placeholderPosition && isInSetAssignmentList(sql, setIndex, placeholderPosition)) | ||
| escapedValue = objectToValues(currentValue, timezone); | ||
| else escapedValue = escape(currentValue, true, timezone); | ||
| } | ||
| } else escapedValue = escape(currentValue, true, timezone); | ||
@@ -294,0 +446,0 @@ } else escapedValue = escape(currentValue, stringifyObjects, timezone); |
+1
-1
| export type Raw = { | ||
| toSqlString(): string; | ||
| }; | ||
| export type SqlValue = string | number | bigint | boolean | Date | Buffer | Uint8Array | Raw | Record<string, unknown> | SqlValue[] | null | undefined; | ||
| export type SqlValue = string | number | bigint | boolean | Date | Buffer | Uint8Array | Raw | Record<string, unknown> | SqlValue[] | Set<SqlValue> | Map<string, SqlValue> | null | undefined; | ||
| export type Timezone = 'local' | 'Z' | (string & NonNullable<unknown>); |
+4
-5
| { | ||
| "name": "sql-escaper", | ||
| "version": "1.3.3", | ||
| "version": "1.4.0", | ||
| "description": "🛡️ Faster SQL escape and format for JavaScript (Node.js, Bun, and Deno).", | ||
@@ -34,4 +34,3 @@ "main": "./lib/index.js", | ||
| "test:node": "poku", | ||
| "test:bun": "bun poku", | ||
| "test:deno": "deno run -A npm:poku", | ||
| "test:bun": "bun --bun poku", | ||
| "test:coverage": "mcr --import tsx --config mcr.config.ts npm run test:node", | ||
@@ -46,6 +45,6 @@ "lint": "biome lint && prettier --check .", | ||
| "@types/node": "^25.2.0", | ||
| "esbuild": "^0.27.2", | ||
| "esbuild": "^0.28.1", | ||
| "monocart-coverage-reports": "^2.12.9", | ||
| "packages-update": "^2.0.0", | ||
| "poku": "^3.0.3-canary.13a996a9", | ||
| "poku": "^4.3.2", | ||
| "prettier": "^3.8.1", | ||
@@ -52,0 +51,0 @@ "tsx": "^4.21.0", |
+112
-42
@@ -91,3 +91,3 @@ # SQL Escaper | ||
| escape("Hello 'World'"); | ||
| escape("Hello 'World'", true); | ||
| // => "'Hello \\'World\\''" | ||
@@ -104,3 +104,3 @@ | ||
| escape(raw('NOW()')); | ||
| escape(raw('NOW()'), true); | ||
| // => 'NOW()' | ||
@@ -136,8 +136,8 @@ ``` | ||
| ```js | ||
| escape(undefined); // 'NULL' | ||
| escape(null); // 'NULL' | ||
| escape(true); // 'true' | ||
| escape(false); // 'false' | ||
| escape(5); // '5' | ||
| escape("Hello 'World"); // "'Hello \\'World'" | ||
| escape(undefined, true); // 'NULL' | ||
| escape(null, true); // 'NULL' | ||
| escape(true, true); // 'true' | ||
| escape(false, true); // 'false' | ||
| escape(5, true); // '5' | ||
| escape("Hello 'World", true); // "'Hello \\'World'" | ||
| ``` | ||
@@ -150,3 +150,3 @@ | ||
| ```js | ||
| escape(new Date(2012, 4, 7, 11, 42, 3, 2)); | ||
| escape(new Date(2012, 4, 7, 11, 42, 3, 2), true); | ||
| // => "'2012-05-07 11:42:03.002'" | ||
@@ -158,3 +158,3 @@ ``` | ||
| ```js | ||
| escape(new Date(NaN)); // 'NULL' | ||
| escape(new Date(NaN), true); // 'NULL' | ||
| ``` | ||
@@ -167,5 +167,5 @@ | ||
| escape(date, false, 'Z'); // "'2012-05-07 11:42:03.002'" | ||
| escape(date, false, '+01'); // "'2012-05-07 12:42:03.002'" | ||
| escape(date, false, '-05:00'); // "'2012-05-07 06:42:03.002'" | ||
| escape(date, true, 'Z'); // "'2012-05-07 11:42:03.002'" | ||
| escape(date, true, '+01'); // "'2012-05-07 12:42:03.002'" | ||
| escape(date, true, '-05:00'); // "'2012-05-07 06:42:03.002'" | ||
| ``` | ||
@@ -178,3 +178,3 @@ | ||
| ```js | ||
| escape(Buffer.from([0, 1, 254, 255])); | ||
| escape(Buffer.from([0, 1, 254, 255]), true); | ||
| // => "X'0001feff'" | ||
@@ -185,10 +185,17 @@ ``` | ||
| When `stringifyObjects` is set to a non-nullish value **(recommended)**, objects are stringified instead of being expanded into key-value pairs: | ||
| ```js | ||
| escape({ a: 'b' }, true); | ||
| // => "'[object Object]'" | ||
| ``` | ||
| Objects with a `toSqlString` method will have that method called: | ||
| ```js | ||
| escape({ toSqlString: () => 'NOW()' }); | ||
| escape({ toSqlString: () => 'NOW()' }, true); | ||
| // => 'NOW()' | ||
| ``` | ||
| Plain objects are converted to `key = value` pairs: | ||
| Plain objects are converted to `key = value` pairs **(discouraged)**: | ||
@@ -200,3 +207,3 @@ ```js | ||
| Function properties in objects are ignored: | ||
| Function properties in objects are ignored **(discouraged)**: | ||
@@ -208,8 +215,36 @@ ```js | ||
| When `stringifyObjects` is set to a non-nullish value, objects are stringified instead of being expanded into key-value pairs: | ||
| > [!CAUTION] | ||
| > | ||
| > Without `stringifyObjects`, plain objects are converted to `key = value` pairs, so an object reaching `escape` where a value is expected reshapes the query, for example: | ||
| > | ||
| > ```js | ||
| > const userInput = { id: true }; // e.g. JSON.parse('{"id":true}') | ||
| > | ||
| > /** Unsafe 🔓 (value position) */ | ||
| > 'DELETE FROM entries WHERE id = ' + escape(userInput); | ||
| > // => "DELETE FROM entries WHERE id = `id` = true" | ||
| > // `id` = `id` is always true, so every row is deleted ❗️ | ||
| > | ||
| > /** Valid ✅ (SET assignment) */ | ||
| > 'UPDATE users SET ' + escape({ name: 'foo', role: 'admin' }); | ||
| > // => "UPDATE users SET `name` = 'foo', `role` = 'admin'" | ||
| > ``` | ||
| ```js | ||
| escape({ a: 'b' }, true); | ||
| // => "'[object Object]'" | ||
| ``` | ||
| > [!TIP] | ||
| > | ||
| > Instead, `format` only expands an object where it is safe (e.g., a `SET` assignment) and stringifies it everywhere else, so the same input cannot reshape the query: | ||
| > | ||
| > ```js | ||
| > const userInput = { id: true }; // e.g. JSON.parse('{"id":true}') | ||
| > | ||
| > /** Unsafe 🔐 (value position) */ | ||
| > format('DELETE FROM entries WHERE id = ?', [userInput]); | ||
| > // => "DELETE FROM entries WHERE id = '[object Object]'" | ||
| > // stringified to an inert value | ||
| > | ||
| > /** Valid ✅ (SET assignment) */ | ||
| > format('UPDATE users SET ?', [{ name: 'foo', role: 'admin' }]); | ||
| > // => "UPDATE users SET `name` = 'foo', `role` = 'admin'" | ||
| > // expanded on purpose | ||
| > ``` | ||
@@ -221,3 +256,3 @@ #### Arrays | ||
| ```js | ||
| escape([1, 2, 'c']); | ||
| escape([1, 2, 'c'], true); | ||
| // => "1, 2, 'c'" | ||
@@ -229,9 +264,21 @@ ``` | ||
| ```js | ||
| escape([ | ||
| [1, 2, 3], | ||
| [4, 5, 6], | ||
| ]); | ||
| escape( | ||
| [ | ||
| [1, 2, 3], | ||
| [4, 5, 6], | ||
| ], | ||
| true | ||
| ); | ||
| // => '(1, 2, 3), (4, 5, 6)' | ||
| ``` | ||
| #### Sets | ||
| Sets are treated like arrays, turning into comma-separated lists with natural deduplication: | ||
| ```js | ||
| escape(new Set([1, 2, 3]), true); | ||
| // => '1, 2, 3' | ||
| ``` | ||
| --- | ||
@@ -326,2 +373,23 @@ | ||
| #### Maps in SET clauses | ||
| Maps are treated like plain objects, preserving insertion order. In `SET` or `ON DUPLICATE KEY UPDATE` contexts, they are expanded into `key = value` pairs: | ||
| ```js | ||
| format('UPDATE users SET ?', [ | ||
| new Map([ | ||
| ['name', 'foo'], | ||
| ['email', 'bar@test.com'], | ||
| ]), | ||
| ]); | ||
| // => "UPDATE users SET `name` = 'foo', `email` = 'bar@test.com'" | ||
| ``` | ||
| Outside of `SET` or `ON DUPLICATE KEY UPDATE`, a `Map` is stringified as `'[object Map]'`, the same security measure applied to objects: | ||
| ```js | ||
| format('SELECT * FROM users WHERE data = ?', [new Map([['id', 1]])]); | ||
| // => "SELECT * FROM users WHERE data = '[object Map]'" | ||
| ``` | ||
| --- | ||
@@ -338,5 +406,9 @@ | ||
| ```js | ||
| escape(raw('NOW()')); | ||
| escape(raw('NOW()'), true); | ||
| // => 'NOW()' | ||
| ``` | ||
| Inside an expanded object, raw values are preserved **(discouraged)**: | ||
| ```js | ||
| escape({ id: raw('LAST_INSERT_ID()') }); | ||
@@ -366,12 +438,15 @@ // => '`id` = LAST_INSERT_ID()' | ||
| Each benchmark formats `10,000` queries using `format` with `100` mixed values (numbers, strings, `null`, and dates), comparing **SQL Escaper** against the original [**sqlstring**](https://github.com/mysqljs/sqlstring) through [**hyperfine**](https://github.com/sharkdp/hyperfine): | ||
| Each benchmark formats `10,000` queries using `format` with `100` values, comparing **SQL Escaper** against the original [**sqlstring**](https://github.com/mysqljs/sqlstring) through [**hyperfine**](https://github.com/sharkdp/hyperfine): | ||
| | Benchmark | sqlstring | SQL Escaper | Difference | | ||
| | ---------------------------------------- | --------: | ----------: | ---------------: | | ||
| | Select 100 values | 248.8 ms | 178.7 ms | **1.39x faster** | | ||
| | Insert 100 values | 247.5 ms | 196.2 ms | **1.26x faster** | | ||
| | SET with 100 values | 257.5 ms | 205.2 ms | **1.26x faster** | | ||
| | SET with 100 objects | 348.3 ms | 250.5 ms | **1.39x faster** | | ||
| | ON DUPLICATE KEY UPDATE with 100 values | 466.2 ms | 394.6 ms | **1.18x faster** | | ||
| | ON DUPLICATE KEY UPDATE with 100 objects | 558.2 ms | 433.9 ms | **1.29x faster** | | ||
| | # | Benchmark | sqlstring | SQL Escaper | Difference | | ||
| | --: | ---------------------------------------- | --------: | ----------: | ---------------: | | ||
| | 1 | Select 100 values | 249.0 ms | 177.8 ms | **1.40x faster** | | ||
| | 2 | Insert 100 values | 247.7 ms | 185.3 ms | **1.34x faster** | | ||
| | 3 | Insert 100 strings requiring escape | 436.9 ms | 257.5 ms | **1.70x faster** | | ||
| | 4 | Insert 100 dates | 611.9 ms | 415.1 ms | **1.47x faster** | | ||
| | 5 | SET with 100 values | 258.8 ms | 207.4 ms | **1.25x faster** | | ||
| | 6 | SET with 100 objects | 344.8 ms | 241.0 ms | **1.43x faster** | | ||
| | 7 | ON DUPLICATE KEY UPDATE with 100 values | 462.0 ms | 362.7 ms | **1.27x faster** | | ||
| | 8 | ON DUPLICATE KEY UPDATE with 100 objects | 559.4 ms | 437.8 ms | **1.28x faster** | | ||
| | 9 | Multi-statement SET with 100 objects | 348.7 ms | 243.4 ms | **1.43x faster** | | ||
@@ -402,11 +477,6 @@ - See detailed results and how the benchmarks are run in the [**benchmark**](https://github.com/mysqljs/sql-escaper/tree/main/benchmark) directory. | ||
| - The escaping methods in this library only work when the [`NO_BACKSLASH_ESCAPES`](https://dev.mysql.com/doc/refman/5.7/en/sql-mode.html#sqlmode_no_backslash_escapes) SQL mode is disabled (which is the default state for MySQL servers). | ||
| - This library performs **client-side escaping** to generate SQL strings. The syntax for `format` may look similar to a prepared statement, but it is not — the escaping rules from this module are used to produce the resulting SQL string. | ||
| - When using `format`, **all** `?` placeholders are replaced, including those contained in comments and strings. | ||
| - When structured user input is provided as the value to escape, care should be taken to validate the shape of the input, as the resulting escaped string may contain more than a single value. | ||
| - `NaN` and `Infinity` are left as-is. MySQL does not support these values, and trying to insert them will trigger MySQL errors. | ||
| - The string provided to `raw()` will **skip all escaping**, so be careful when passing in unvalidated input. | ||
@@ -413,0 +483,0 @@ |
56569
26.91%1034
42.42%495
16.47%