|
1 | 1 | import _ from 'lodash'; |
2 | 2 | import { isJsonObject } from 'tiny-essentials'; |
3 | 3 |
|
4 | | -/** @typedef {{ title: string; parser?: function(string): string }} SpecialQuery */ |
| 4 | +/** @typedef {{ title: string; parser?: (value: string) => string }} SpecialQuery */ |
5 | 5 | /** @typedef {import('./PuddySqlQuery.mjs').Pcache} Pcache */ |
6 | 6 | /** @typedef {import('./PuddySqlQuery.mjs').TagCriteria} TagCriteria */ |
7 | 7 |
|
@@ -235,6 +235,7 @@ class PuddySqlTags { |
235 | 235 | * |
236 | 236 | * @param {Object} config - The special query object to be added. |
237 | 237 | * @param {string} config.title - The unique title identifier of the special query. |
| 238 | + * @param {(value: string) => string} [config.parser] The special query function to convert the final value. |
238 | 239 | */ |
239 | 240 | addSpecialQuery(config) { |
240 | 241 | if (!isJsonObject(config) || typeof config.title !== 'string') |
@@ -520,6 +521,84 @@ class PuddySqlTags { |
520 | 521 | return where.length ? `(${where.join(' AND ')})` : '1'; |
521 | 522 | } |
522 | 523 |
|
| 524 | + /** |
| 525 | + * Builds an SQL WHERE clause for "flat" tag tables (one tag per row). |
| 526 | + * |
| 527 | + * Works like parseWhere, but does NOT use EXISTS/json_each. |
| 528 | + * Filters rows by direct equality or LIKE, supports negation (!tag), |
| 529 | + * wildcards, OR/AND groups, and updates pCache for parameterized queries. |
| 530 | + * |
| 531 | + * @param {TagCriteria} [group={}] - Tag group definition |
| 532 | + * @param {Pcache} [pCache={ index: 1, values: [] }] - Placeholder cache object |
| 533 | + * @returns {string} SQL WHERE clause string |
| 534 | + */ |
| 535 | + parseWhereFlat(group = {}, pCache = { index: 1, values: [] }) { |
| 536 | + if (!isJsonObject(pCache)) |
| 537 | + throw new TypeError(`Expected pCache to be a valid object, but got ${typeof pCache}`); |
| 538 | + if (!isJsonObject(group)) |
| 539 | + throw new TypeError(`Expected group to be a valid object, but got ${typeof group}`); |
| 540 | + if (typeof pCache.index !== 'number') |
| 541 | + throw new TypeError(`Invalid or missing pCache.index; expected number`); |
| 542 | + if (!Array.isArray(pCache.values)) |
| 543 | + throw new TypeError(`Invalid or missing pCache.values; expected array`); |
| 544 | + |
| 545 | + const where = []; |
| 546 | + const tagsColumn = group.column || 'tag'; |
| 547 | + const allowWildcards = typeof group.allowWildcards === 'boolean' ? group.allowWildcards : false; |
| 548 | + const include = Array.isArray(group.include) ? group.include : []; |
| 549 | + |
| 550 | + /** |
| 551 | + * @param {string} tag |
| 552 | + * @returns {{ param: string; usesWildcard: boolean; not: boolean; }} |
| 553 | + */ |
| 554 | + const filterTag = (tag) => { |
| 555 | + if (typeof tag !== 'string') |
| 556 | + throw new TypeError(`Each tag must be a string, but received: ${typeof tag}`); |
| 557 | + |
| 558 | + const not = tag.startsWith('!'); |
| 559 | + const cleanTag = not ? tag.slice(1) : tag; |
| 560 | + |
| 561 | + if (typeof pCache.index !== 'number') throw new Error('Invalid pCache index'); |
| 562 | + const param = `$${pCache.index++}`; |
| 563 | + |
| 564 | + const usesWildcard = |
| 565 | + allowWildcards && |
| 566 | + (cleanTag.includes(this.#wildcardA) || cleanTag.includes(this.#wildcardB)); |
| 567 | + |
| 568 | + const filteredTag = usesWildcard |
| 569 | + ? cleanTag |
| 570 | + .replace(/([%_])/g, '\\$1') |
| 571 | + .replaceAll(this.#wildcardA, '%') |
| 572 | + .replaceAll(this.#wildcardB, '_') |
| 573 | + : cleanTag; |
| 574 | + |
| 575 | + if (!Array.isArray(pCache.values)) throw new Error('Invalid pCache values'); |
| 576 | + pCache.values.push(filteredTag); |
| 577 | + return { param, usesWildcard, not }; |
| 578 | + }; |
| 579 | + |
| 580 | + /** @param {{ param: string; usesWildcard: boolean; not: boolean; }} tagObj */ |
| 581 | + const createQuery = (tagObj) => { |
| 582 | + const { param, usesWildcard, not } = tagObj; |
| 583 | + const operator = usesWildcard ? 'LIKE' : '='; |
| 584 | + return `${tagsColumn} ${not ? '!=' : operator} ${param}`; |
| 585 | + }; |
| 586 | + |
| 587 | + for (const clause of include) { |
| 588 | + if (Array.isArray(clause)) { |
| 589 | + // OR group |
| 590 | + const ors = clause.map((tag) => createQuery(filterTag(tag))); |
| 591 | + if (ors.length) where.push(`(${ors.join(' OR ')})`); |
| 592 | + } else { |
| 593 | + // single tag |
| 594 | + where.push(createQuery(filterTag(clause))); |
| 595 | + } |
| 596 | + } |
| 597 | + |
| 598 | + // Combine all with AND |
| 599 | + return where.length ? `(${where.join(' AND ')})` : '1'; |
| 600 | + } |
| 601 | + |
523 | 602 | /** |
524 | 603 | * Extracts special query elements and custom tag input groups from parsed search chunks. |
525 | 604 | * |
@@ -825,7 +904,7 @@ class PuddySqlTags { |
825 | 904 | .map((item) => item.trim()) |
826 | 905 | .join(' AND ') |
827 | 906 | .replace(/(?:^|[\s(,])-(?=\w)/g, (match) => match.replace('-', '!')) |
828 | | - .replace(/\bNOT\b/g, '!') |
| 907 | + .replace(/\s*\bNOT\b\s*/g, '!') |
829 | 908 | .replace(/\&\&/g, 'AND') |
830 | 909 | .replace(/\|\|/g, 'OR'), |
831 | 910 | strictMode, |
|
0 commit comments