puddysql
Version:
🍮 Powerful SQL toolkit for Node.js, built with flexibility and structure in mind. Easily manage SQLite3/PostgreSQL, advanced queries, smart tag systems, and full JSON-friendly filters.
919 lines (822 loc) • 34.1 kB
JavaScript
;
var _ = require('lodash');
var tinyEssentials = require('tiny-essentials');
/** @typedef {{ title: string; parser?: (value: string) => string }} SpecialQuery */
/** @typedef {import('./PuddySqlQuery.mjs').Pcache} Pcache */
/** @typedef {import('./PuddySqlQuery.mjs').TagCriteria} TagCriteria */
/**
* Represents a key-value pair extracted from a special chunk format.
*
* @typedef {Object} SpecialFromChunks
* @property {string} key - The key or identifier extracted from the chunk.
* @property {string} value - The associated value linked to the key.
*/
/**
* Represents a collection of string chunks used in parsing or filtering.
*
* @typedef {Array<string | string[]>} Chunks
*
* A chunk can be a single string or an array of strings grouped as OR conditions.
*/
/**
* Result of parsing a string expression into a column and list of included values.
*
* @typedef {Object} ParseStringResult
* @property {string} column - The SQL column to which the values apply.
* @property {Chunks} include - List of values or grouped OR conditions to be included in the query.
*/
/**
* Represents a mapping entry for a tag input definition.
*
* Each tag input is defined by the name of the list where it belongs (`list`)
* and the key (`valueKey`) used to extract the relevant value from tag objects.
* This structure is used to determine how tags are parsed and grouped during search.
*
* @typedef {Object} TagInput
* @property {string} list - The list name where the tag will be added.
* @property {string} valueKey - The key for the value associated with the tag input.
*/
/**
* @class PuddySqlTags
* @description A powerful utility class for building advanced SQL WHERE clauses with support for tag-based filtering,
* custom boolean logic, wildcard parsing, and special query handlers.
*
* PuddySqlTags provides a structured way to interpret and transform flexible user search input into robust SQL conditions,
* including support for parentheses grouping, AND/OR logic, special colon-based filters, and customizable weight systems
* using symbolic operators. Designed with modularity and extensibility in mind, it also prevents unwanted repetitions and
* allows precise control over column names, aliases, and JSON handling through `json_each`.
*
* The class includes:
* - Methods to parse complex string-based filters (`parseString`, `safeParseString`)
* - Smart logic to detect and manage tag groups, boolean relationships, and custom operators
* - Support for boost values, exclusions, and other modifiers via symbols (e.g., `-`, `!`)
* - An internal engine to dynamically build `EXISTS`-based SQL conditions compatible with JSON arrays
* - Integration-ready output for SQLite3, Postgre or similar relational databases
*
* ----------
* 💖 Special Thanks 💖
* Deep gratitude to the Derpibooru project for the inspiration, structure, and creativity
* that influenced this tool. A tiny heartfelt thank you to **Nighty**. :3
*/
class PuddySqlTags {
/**
* json_each
*
* @type {string|null}
*/
#jsonEach = 'json_each';
/** @type {SpecialQuery[]} */
#specialQueries = [];
#defaultColumn = '';
#wildcardA = '*';
#wildcardB = '?';
#noRepeat = false;
#useJsonEach = true;
#parseLimit = -1;
/** @type {string|null} */
#defaultTableName = null;
/**
* Creates an instance of the PuddySqlTags class.
* @param {string} defaultColumn - The default column name to use in queries (default is 'tags').
*/
constructor(defaultColumn = 'tags') {
this.setColumnName(defaultColumn);
}
/**
* #tagInputs is a private property that holds predefined mappings for special symbols
* used to categorize and assign values to specific tag lists. These mappings help in organizing
* tags based on their associated symbols and their corresponding value keys.
*
* - `'^'`: Maps to the 'boosts' list, with the associated value key being 'boost'.
* - `'~'`: Maps to the 'fuzzies' list, with the associated value key being 'fuzzy'.
*
* These mappings enable flexible handling of tags, where the symbols (`^`, `~`, etc.) can be used
* to categorize tags dynamically and assign values to them based on their symbol.
*
* @type {Object<string, TagInput>}
* @example
* // Example usage:
* const symbolMapping = this.#tagInputs['^'];
* // symbolMapping will be { list: 'boosts', valueKey: 'boost' }
*/
#tagInputs = {
'^': { list: 'boosts', valueKey: 'boost' },
'~': { list: 'fuzzies', valueKey: 'fuzzy' },
};
/**
* Adds a new tag input mapping to the #tagInputs property.
*
* This method allows dynamic addition of new tag input mappings by providing a `key`,
* `list`, and `valueKey`. It validates the types of `list` and `valueKey`, and
* prevents adding a tag with the list name "include" and "column" as it is restricted.
*
* @param {string} key - The key (symbol) to associate with the tag input.
* @param {string} list - The list name where the tag will be added.
* @param {string} valueKey - The key for the value associated with the tag input.
* @throws {Error} Throws an error if `list` or `valueKey` are not strings,
* or if the `list` name is "include" or "column".
*/
addTagInput(key, list, valueKey) {
// Validation to ensure 'list' and 'valueKey' are strings
if (typeof list !== 'string' || typeof valueKey !== 'string') {
throw new TypeError('Both list and valueKey must be strings');
}
// Prevents adding a tag with the list name "include"
if (list === 'include') {
throw new Error('Cannot add a tag with the list name "include"');
}
// Prevents adding a tag with the list name "column"
if (list === 'column') {
throw new Error('Cannot add a tag with the list name "column"');
}
// Adds the new tag input to #tagInputs
this.#tagInputs[key] = { list, valueKey };
}
/**
* Checks if a tag input mapping exists for the given key.
*
* @param {string} key - The tag key to check.
* @returns {boolean} `true` if the key exists in `#tagInputs`, otherwise `false`.
* @throws {TypeError} If `key` is not a string.
*/
hasTagInput(key) {
if (typeof key !== 'string') throw new TypeError('key must be a string');
if (this.#tagInputs.hasOwnProperty(key)) return true;
return false;
}
/**
* Removes a tag input mapping from the `#tagInputs` object.
*
* If the key exists, it will be deleted.
* Otherwise, an error is thrown.
*
* @param {string} key - The key of the tag input to remove.
* @throws {Error} If the specified key does not exist in `#tagInputs`.
* @throws {TypeError} If `key` is not a string.
*/
removeTagInput(key) {
// Check if the key exists in the #tagInputs object
if (this.hasTagInput(key)) {
// Delete the tag input if it exists
delete this.#tagInputs[key];
}
throw new Error(`Tag input key '${key}' does not exist.`);
}
/**
* Gets the title of the first item for a given tag input key.
*
* @param {string} key - The key of the tag input to retrieve.
* @returns {TagInput} The title of the first item.
* @throws {TypeError} If `key` is not a string.
* @throws {Error} If the key does not exist or has no valid title.
*/
getTagInput(key) {
if (typeof key !== 'string') throw new TypeError('Tag input key must be a string');
if (!this.#tagInputs[key]) throw new Error(`Tag input '${key}' is missing.`);
return { ...this.#tagInputs[key] };
}
/**
* Gets an array of all tag input titles.
*
* @returns {TagInput[]} An array containing the tag inputs.
*/
getAllTagInput() {
return Object.entries(this.#tagInputs).map(([key, entry]) => ({ ...entry }));
}
/**
* Sets whether repeated tags are allowed.
* Internally sets `this.#noRepeat` to the inverse of the boolean value provided.
* If value is not a boolean, resets `noRepeat` to null.
*
* @param {boolean} value - True to allow repeated tags, false to prevent them.
*/
setCanRepeat(value) {
if (typeof value !== 'boolean') throw new TypeError('value must be a boolean');
this.#noRepeat = !value;
}
/**
* Sets the wildcard symbol used in the search expression.
* Only updates if the value is a string.
*
* @param {'wildcardA'|'wildcardB'} where - Which wildcard to set.
* @param {string|null} value - The wildcard symbol (e.g. '*', '%').
*/
setWildcard(where, value) {
if (where !== 'wildcardA' && where !== 'wildcardB')
throw new Error("where must be 'wildcardA' or 'wildcardB'");
if (typeof value !== 'string') throw new TypeError('value must be a string');
if (where === 'wildcardA') this.#wildcardA = value;
if (where === 'wildcardB') this.#wildcardB = value;
}
/**
* Adds a new custom special query to the internal list.
* Special queries can affect how tags are interpreted or matched.
*
* @param {Object} config - The special query object to be added.
* @param {string} config.title - The unique title identifier of the special query.
* @param {(value: string) => string} [config.parser] The special query function to convert the final value.
*/
addSpecialQuery(config) {
if (!tinyEssentials.isJsonObject(config) || typeof config.title !== 'string')
throw new TypeError('config must be an object with a string "title"');
this.#specialQueries.push(config);
}
/**
* Checks if a special query with the given title exists.
*
* @param {string} title - The title of the special query to check.
* @returns {boolean} `true` if a special query with the title exists, otherwise `false`.
* @throws {TypeError} If `title` is not a string.
*/
hasSpecialQuery(title) {
if (typeof title !== 'string') throw new TypeError('title must be a string');
if (this.#specialQueries.findIndex((item) => item.title === title)) return true;
return false;
}
/**
* Removes a special query identified by its title.
*
* If a query with the specified title exists, it is removed.
* Otherwise, an error is thrown.
*
* @param {string} title - The title of the special query to remove.
* @throws {TypeError} If `title` is not a string.
* @throws {Error} If no special query with the given title exists.
*/
removeSpecialQuery(title) {
if (typeof title !== 'string') throw new TypeError('title must be a string');
const index = this.#specialQueries.findIndex((item) => item.title === title);
if (index > -1) {
this.#specialQueries.splice(index, 1);
return;
}
throw new Error(`Special query with title '${title}' does not exist.`);
}
/**
* Retrieves the title of a special query by its title key.
*
* This method checks if a special query with the given title exists
* and returns its `parser` value. If not found, it throws an error.
*
* @param {string} title - The title of the special query to retrieve.
* @returns {(function(string): string) | null} The function of the found special query.
* @throws {TypeError} If `title` is not a string.
* @throws {Error} If no special query with the given title exists.
*/
getSpecialQuery(title) {
if (typeof title !== 'string') throw new TypeError('title must be a string');
const item = this.#specialQueries.find((entry) => entry.title === title);
if (!item) throw new Error(`No special query found with title '${title}'`);
return item.parser || null;
}
/**
* Returns a list of all special query titles.
*
* This is a shallow extraction of the `title` field from every item
* in the internal `#specialQueries` array.
*
* @returns {string[]} An array of all special query titles.
*/
getAllSpecialQuery() {
return this.#specialQueries.map((item) => item.title);
}
/**
* Sets the name of the default SQL column used when building tag-based conditions.
*
* @param {string} value - Column name to be used as default (e.g. 'tags').
*/
setColumnName(value) {
if (typeof value !== 'string') throw new TypeError('value must be a string');
this.#defaultColumn = value;
}
/**
* Gets the current default SQL column name used for tag conditions.
*
* @returns {string} The name of the default column.
*/
getColumnName() {
return this.#defaultColumn;
}
/**
* Sets a limit on the number of items parsed from the search string.
* Used to avoid overloading the engine with too many conditions.
*
* @param {number} value - Maximum number of items to parse (use -1 for no limit).
*/
setParseLimit(value) {
if (typeof value !== 'number') throw new TypeError('value must be a number');
this.#parseLimit = value;
}
/**
* Gets the current limit on how many tags are parsed from a search string.
*
* @returns {number} The current parse limit.
*/
getParseLimit() {
return this.#parseLimit;
}
/**
* Enables or disables the use of `json_each()` in SQL statements.
* This affects how JSON-based columns are traversed.
*
* @param {boolean} value - Whether to use `json_each()` in tag conditions.
*/
setUseJsonEach(value) {
if (typeof value !== 'boolean') throw new TypeError('value must be a boolean');
this.#useJsonEach = value;
}
/**
* Sets the external table name name used in `EXISTS` subqueries, typically referencing `value`.
*
* @param {string} value - The alias to use in SQL subqueries (e.g. 'value').
*/
setTableName(value) {
if (typeof value !== 'string') throw new TypeError('value must be a string');
this.#defaultTableName = value;
}
/**
* Sets the raw SQL string used for the `json_each()` expression.
* This is used for custom SQL generation.
*
* @param {string} value - The SQL snippet (e.g. "json_each(tags)").
*/
setJsonEach(value) {
if (value !== null && typeof value !== 'string') throw new TypeError('value must be a string');
this.#jsonEach = value;
}
#isPgMode = false;
/**
* Sets whether the engine is running in PostgreSQL mode.
*
* @param {boolean} value - Must be a boolean true/false.
* @throws {TypeError} If the value is not a boolean.
*/
setIsPgMode(value) {
if (typeof value !== 'boolean') {
throw new TypeError(`setIsPgMode() expects a boolean, but received: ${typeof value}`);
}
this.#isPgMode = value;
}
/**
* Gets whether the engine is currently in PostgreSQL mode.
*
* @returns {boolean}
*/
getIsPgMode() {
return this.#isPgMode;
}
/**
* Builds an SQL WHERE clause from a structured tag group definition.
*
* This method supports both direct equality and wildcard matching using custom
* wildcard symbols (`wildcardA`, `wildcardB`). Tags can be negated with a leading `!`.
* It generates nested `EXISTS` or `NOT EXISTS` subqueries depending on the `useJsonEach` flag.
*
* The method returns a string representing the SQL WHERE clause, and updates `pCache.values`
* with the filtered values in proper order for parameterized queries.
*
* @param {Pcache} [pCache={ index: 1, values: [] }] - Placeholder cache object.
* @param {TagCriteria} [group={}] - Tag group definition to build the clause from.
*
* @returns {string} The generated SQL condition string (e.g., `(EXISTS (...)) AND (NOT EXISTS (...))`).
*/
parseWhere(group = {}, pCache = { index: 1, values: [] }) {
if (!tinyEssentials.isJsonObject(pCache))
throw new TypeError(`Expected pCache to be a valid object, but got ${typeof pCache}`);
if (!tinyEssentials.isJsonObject(group))
throw new TypeError(`Expected group to be a valid object, but got ${typeof group}`);
if (typeof pCache.index !== 'number')
throw new TypeError(
`Invalid or missing pCache.index; expected number but got ${typeof pCache.index}`,
);
if (!Array.isArray(pCache.values))
throw new TypeError(
`Invalid or missing pCache.values; expected array but got ${typeof pCache.values}`,
);
const where = [];
const tagsColumn = group.column || this.getColumnName();
const tagsTable = group.tableName || this.#defaultTableName;
const allowWildcards = typeof group.allowWildcards === 'boolean' ? group.allowWildcards : false;
if (!Array.isArray(group.include))
throw new TypeError(
`Expected 'include' to be an array of tags or tag groups, but got ${typeof group.include}`,
);
const include = group.include;
/**
* Generates a subquery for checking tag inclusion/exclusion.
*
* @param {boolean} not - Whether the condition is a negation (NOT).
* @param {string} param - The parameter placeholder (e.g., `$1`, `$2`, etc.).
* @param {boolean} [useLike=false] - Whether to use `LIKE` instead of `=`.
* @returns {string} The SQL snippet for the tag existence check.
* @throws {Error} If SQLite mode is active and `tagsTable` is not defined.
*/
const createQuery = (not, param, useLike = false) => {
// Sqlite3
if (!this.#isPgMode) {
let result;
if (this.#useJsonEach) {
result = `${this.#jsonEach}(${tagsColumn}) WHERE value ${useLike ? 'LIKE' : '='} ${param}`;
} else {
if (typeof tagsTable !== 'string' || tagsTable.trim() === '') {
throw new Error(
`Missing or invalid 'tagsTable'. Expected a non-empty string when using SQLite mode without json_each.`,
);
}
result = `${tagsColumn} WHERE ${tagsTable}.${tagsColumn} ${useLike ? 'LIKE' : '='} ${param}`;
}
return `${not ? 'NOT ' : ''}EXISTS (SELECT 1 FROM ${result})`;
}
// PostgreSQL
else {
return `${not ? 'NOT (' : ''}${param} = ANY(${tagsColumn})${not ? ')' : ''}`;
}
};
/**
* @param {string} tag
* @returns {{ param: string; usesWildcard: boolean; not: boolean; }}
*/
const filterTag = (tag) => {
if (typeof tag !== 'string')
throw new TypeError(`Each tag must be a string, but received: ${typeof tag}`);
const not = tag.startsWith('!');
const cleanTag = not ? tag.slice(1) : tag;
// if (!cleanTag) throw new SyntaxError('Empty tag name after negation (!tag)');
if (typeof pCache.index !== 'number') throw new Error('Invalid pCache index');
const param = `$${pCache.index++}`;
const usesWildcard =
allowWildcards &&
(cleanTag.includes(this.#wildcardA) || cleanTag.includes(this.#wildcardB));
const filteredTag = usesWildcard
? cleanTag
.replace(/([%_])/g, '\\$1')
.replaceAll(this.#wildcardA, '%')
.replaceAll(this.#wildcardB, '_')
: cleanTag;
if (!Array.isArray(pCache.values)) throw new Error('Invalid pCache values');
pCache.values.push(filteredTag);
return { param, usesWildcard, not };
};
for (const clause of include) {
if (Array.isArray(clause)) {
// if (!clause.length) throw new SyntaxError('Empty OR group inside "include" array');
const ors = clause.map((tag) => {
const { param, usesWildcard, not } = filterTag(tag);
return createQuery(not, param, usesWildcard);
});
if (ors.length) where.push(`(${ors.join(' OR ')})`);
} else {
const { param, usesWildcard, not } = filterTag(clause);
where.push(createQuery(not, param, usesWildcard));
}
}
// Only AND between the conditions generated
return where.length ? `(${where.join(' AND ')})` : '1';
}
/**
* Builds an SQL WHERE clause for "flat" tag tables (one tag per row).
*
* Works like parseWhere, but does NOT use EXISTS/json_each.
* Filters rows by direct equality or LIKE, supports negation (!tag),
* wildcards, OR/AND groups, and updates pCache for parameterized queries.
*
* @param {TagCriteria} [group={}] - Tag group definition
* @param {Pcache} [pCache={ index: 1, values: [] }] - Placeholder cache object
* @returns {string} SQL WHERE clause string
*/
parseWhereFlat(group = {}, pCache = { index: 1, values: [] }) {
if (!tinyEssentials.isJsonObject(pCache))
throw new TypeError(`Expected pCache to be a valid object, but got ${typeof pCache}`);
if (!tinyEssentials.isJsonObject(group))
throw new TypeError(`Expected group to be a valid object, but got ${typeof group}`);
if (typeof pCache.index !== 'number')
throw new TypeError(`Invalid or missing pCache.index; expected number`);
if (!Array.isArray(pCache.values))
throw new TypeError(`Invalid or missing pCache.values; expected array`);
const where = [];
const tagsColumn = group.column || 'tag';
const allowWildcards = typeof group.allowWildcards === 'boolean' ? group.allowWildcards : false;
const include = Array.isArray(group.include) ? group.include : [];
/**
* @param {string} tag
* @returns {{ param: string; usesWildcard: boolean; not: boolean; }}
*/
const filterTag = (tag) => {
if (typeof tag !== 'string')
throw new TypeError(`Each tag must be a string, but received: ${typeof tag}`);
const not = tag.startsWith('!');
const cleanTag = not ? tag.slice(1) : tag;
if (typeof pCache.index !== 'number') throw new Error('Invalid pCache index');
const param = `$${pCache.index++}`;
const usesWildcard =
allowWildcards &&
(cleanTag.includes(this.#wildcardA) || cleanTag.includes(this.#wildcardB));
const filteredTag = usesWildcard
? cleanTag
.replace(/([%_])/g, '\\$1')
.replaceAll(this.#wildcardA, '%')
.replaceAll(this.#wildcardB, '_')
: cleanTag;
if (!Array.isArray(pCache.values)) throw new Error('Invalid pCache values');
pCache.values.push(filteredTag);
return { param, usesWildcard, not };
};
/** @param {{ param: string; usesWildcard: boolean; not: boolean; }} tagObj */
const createQuery = (tagObj) => {
const { param, usesWildcard, not } = tagObj;
const operator = usesWildcard ? 'LIKE' : '=';
return `${tagsColumn} ${not ? '!=' : operator} ${param}`;
};
for (const clause of include) {
if (Array.isArray(clause)) {
// OR group
const ors = clause.map((tag) => createQuery(filterTag(tag)));
if (ors.length) where.push(`(${ors.join(' OR ')})`);
} else {
// single tag
where.push(createQuery(filterTag(clause)));
}
}
// Combine all with AND
return where.length ? `(${where.join(' AND ')})` : '1';
}
/**
* Extracts special query elements and custom tag input groups from parsed search chunks.
*
* This method processes a list of parsed string chunks (which may contain modifiers, values,
* or special keywords) and extracts custom input values and predefined special queries.
*
* It uses the configured `#tagInputs` to detect symbol-based values (e.g. score+3, weight*2),
* and `#specialQueries` to detect and parse keys like `source:ponybooru`.
*
* It also updates the input chunks to remove already-processed terms and eliminate repetitions
* when `noRepeat` mode is enabled.
*
* @param {Chunks} chunks - A list of search terms or OR-groups (e.g., ['pony', ['red', 'blue']]).
*
* @returns {{ specials: SpecialFromChunks[] }} An object with:
* - `specials`: An array of extracted special queries `{ key, value }`.
* - one property for each defined group in `#tagInputs`, each holding an array of objects with extracted values.
* Example: `{ boosts: [{ term: "pony", boost: 2 }], specials: [...] }`
*/
#extractSpecialsFromChunks(chunks) {
/** @type {SpecialFromChunks[]} */
const specials = [];
/** @type {Record<string, { term: string; }[]>} */
const outputGroups = {}; // Will store the dynamic groups
/** @type {Record<string, Set<string>>} */
const uniqueMap = {}; // Will store the dynamic sets
// Initiating sets for each group set in #tagInputs
for (const symbol in this.#tagInputs) {
const { list } = this.#tagInputs[symbol];
outputGroups[list] = [];
uniqueMap[list] = new Set();
}
const uniqueTags = new Set();
for (let i = chunks.length - 1; i >= 0; i--) {
const group = chunks[i];
const terms = Array.isArray(group) ? group : [group];
const remainingTerms = [];
for (const term of terms) {
let matched = false;
// Checking if the term contains any of the symbols set in #tagInputs
for (const symbol in this.#tagInputs) {
if (term.includes(symbol)) {
const { list, valueKey } = this.#tagInputs[symbol];
const [termValue, rawValue] = term.split(symbol);
let value = parseFloat(rawValue.replace(/\!/g, '-'));
if (Number.isNaN(value)) value = 1;
// Adds the value to the respective group if it has not yet been processed
if (!uniqueMap[list].has(termValue.trim())) {
outputGroups[list].push({ term: termValue.trim(), [valueKey]: value });
uniqueMap[list].add(termValue.trim());
}
// Checking if the term has already been added in the unique tag set
if (!this.#noRepeat || Array.isArray(group) || !uniqueTags.has(termValue.trim())) {
remainingTerms.push(termValue.trim());
if (!Array.isArray(group)) uniqueTags.add(termValue.trim());
}
matched = true;
break; // For verification after the first corresponding symbol
}
}
if (matched) continue;
// Specials with ":"
if (term.includes(':')) {
const [key, ...rest] = term.split(':');
const value = rest.join(':');
const found = this.#specialQueries.find((q) => q.title === key);
if (found && value !== undefined) {
let parsedValue = value;
if (typeof found.parser === 'function') parsedValue = found.parser(value);
specials.push({ key, value: parsedValue });
} else {
remainingTerms.push(term);
}
} else {
// If it is not a special term, it usually treats or allows repetition within groups
if (!this.#noRepeat || Array.isArray(group) || !uniqueTags.has(term)) {
remainingTerms.push(term);
if (!Array.isArray(group)) uniqueTags.add(term);
}
}
}
// If no terms remain, remove the group
if (remainingTerms.length === 0) {
chunks.splice(i, 1);
} else {
chunks[i] = Array.isArray(group) ? remainingTerms : remainingTerms[0];
}
}
// Returns all dynamically generated groups
return {
specials,
...outputGroups,
};
}
/**
* Parses a search input string into structured query components.
*
* This method tokenizes the input string based on grouping (parentheses), logical
* operators (`AND`, `OR`), and quoting (single or double). It supports optional
* repetition control (`noRepeat`) and a configurable tag limit (`parseLimit`).
*
* The output is normalized into an `include` list of tags or OR-groups (arrays),
* as well as dynamic sets of extracted metadata like `boosts`, `specials`, etc.
*
* This parser supports expressions like:
* `applejack^2, "rainbow dash", (solo OR duo), pudding AND source:ponybooru`
*
* @param {string} input - The raw input string provided by the user.
* @param {boolean} [strictMode=false] - Enables strict validation checks.
* @param {Object} [strictConfig={}] - Optional validation rules for strict mode:
* @param {boolean} [strictConfig.emptyInput=true] - Throw if input is empty after trimming.
* @param {boolean} [strictConfig.parseLimit=true] - Enforce the parse limit (`this.parseLimit`).
* @param {boolean} [strictConfig.openParens=true] - Require balanced parentheses.
* @param {boolean} [strictConfig.quoteChar=true] - Require closing quotes if one is opened.
*
* @returns {ParseStringResult} An object containing:
* - `column`: The column name from `this.getColumnName()`.
* - `include`: Array of tags and OR-groups to include in the query.
* - Additional properties (e.g., `boosts`, `specials`) depending on matches in `#tagInputs` or `#specialQueries`.
*
* Example return:
* ```js
* {
* column: 'tags',
* include: ['applejack', ['solo', 'duo'], 'pudding'],
* boosts: [{ term: 'applejack', boost: 2 }],
* specials: [{ key: 'source', value: 'ponybooru' }]
* }
* ```
*/
parseString(input, strictMode = false, strictConfig = {}) {
if (typeof input !== 'string') {
throw new TypeError(`Expected input to be a string, but received ${typeof input}`);
}
const strictCfg = _.defaults(strictConfig, {
emptyInput: true,
parseLimit: true,
openParens: true,
quoteChar: true,
});
input = input.replace(/\s+/g, ' ').trim();
if (strictMode && strictCfg.emptyInput && !input.length) {
throw new Error('Input string is empty after trimming');
}
/** @type {Chunks} */
const chunks = [];
/** @type {string[]} */
let currentGroup = [];
let buffer = '';
let inQuotes = false;
let quoteChar = '';
const uniqueTags = new Set(); // Para garantir que não existam tags duplicadas
let inGroup = false;
let openParens = 0;
let tagCount = 0;
const flushBuffer = () => {
const value = buffer.trim();
if (!value) return;
if (this.#parseLimit < 0 || tagCount < this.#parseLimit) {
if (!this.#noRepeat || inGroup || !uniqueTags.has(value)) {
currentGroup.push(value);
if (!inGroup) uniqueTags.add(value);
tagCount++;
}
} else if (strictMode && strictCfg.parseLimit) {
throw new Error(`Exceeded tag parse limit of ${this.#parseLimit}`);
}
buffer = '';
};
const flushGroup = () => {
if (currentGroup.length === 1) {
chunks.push(currentGroup[0]);
} else if (currentGroup.length > 1) {
chunks.push([...currentGroup]);
}
currentGroup = [];
};
for (let i = 0; i < input.length; i++) {
const c = input[i];
const next4 = input.slice(i, i + 4).toUpperCase();
const next3 = input.slice(i, i + 3).toUpperCase();
if (inQuotes) {
if (c === quoteChar) {
inQuotes = false;
quoteChar = '';
} else {
buffer += c;
}
continue;
}
if (c === '"' || c === "'") {
inQuotes = true;
quoteChar = c;
continue;
}
if (c === '(') {
openParens++;
flushBuffer();
currentGroup = [];
inGroup = true;
continue;
}
if (c === ')') {
if (strictMode && strictCfg.openParens && openParens <= 0) {
throw new SyntaxError(`Unexpected closing parenthesis at position ${i}`);
}
openParens--;
flushBuffer();
flushGroup();
inGroup = false;
continue;
}
if (next4 === ' AND') {
flushBuffer();
flushGroup();
i += 3;
continue;
}
if (next3 === 'OR ') {
flushBuffer();
i += 2;
continue;
}
buffer += c;
}
if (strictMode) {
if (strictCfg.quoteChar && inQuotes)
throw new SyntaxError(`Unclosed quote starting with ${quoteChar}`);
if (strictCfg.openParens && openParens > 0)
throw new SyntaxError(`Unclosed parenthesis — ${openParens} more ')' expected`);
}
flushBuffer();
flushGroup();
const outputGroups = this.#extractSpecialsFromChunks(chunks);
return { column: this.getColumnName(), include: chunks, ...outputGroups };
}
/**
* Sanitizes and normalizes a raw input string before parsing.
*
* This method prepares user input for parsing by replacing common symbolic
* boolean operators (`&&`, `||`, `-`, `NOT`) with their textual equivalents
* (`AND`, `OR`, `!`). It also trims whitespace and replaces commas with `AND`
* to enforce consistent logical separation.
*
* This is useful when parsing user input that might come from flexible or
* user-friendly interfaces where symbols are more commonly used than
* structured boolean expressions.
*
* @param {string} input - The raw input string provided by the user.
* @param {boolean} [strictMode=false] - Enables strict validation checks.
* @param {Object} [strictConfig={}] - Optional validation rules for strict mode:
* @param {boolean} [strictConfig.emptyInput=true] - Throw if input is empty after trimming.
* @param {boolean} [strictConfig.parseLimit=true] - Enforce the parse limit (`this.parseLimit`).
* @param {boolean} [strictConfig.openParens=true] - Require balanced parentheses.
* @param {boolean} [strictConfig.quoteChar=true] - Require closing quotes if one is opened.
*
* @returns {ParseStringResult} A structured result object returned by `parseString()`,
* containing keys like `column`, `include`, `specials`, `boosts`, etc., depending on
* the tags and expressions detected.
*
* @example
* safeParseString("applejack, -source, rarity || twilight")
* // → equivalent to: parseString("applejack AND !source AND rarity OR twilight")
*/
safeParseString(input, strictMode = false, strictConfig = {}) {
return this.parseString(
input
.split(',')
.map((item) => item.trim())
.join(' AND ')
.replace(/(?:^|[\s(,])-(?=\w)/g, (match) => match.replace('-', '!'))
.replace(/\s*\bNOT\b\s*/g, '!')
.replace(/\&\&/g, 'AND')
.replace(/\|\|/g, 'OR'),
strictMode,
strictConfig,
);
}
}
module.exports = PuddySqlTags;