Skip to content

Commit

Permalink
add more control through configuration @ ParseJSONResultsPlugin.
Browse files Browse the repository at this point in the history
  • Loading branch information
igalklebanov committed Oct 20, 2024
1 parent 66c0e57 commit 3a1825e
Showing 1 changed file with 78 additions and 21 deletions.
99 changes: 78 additions & 21 deletions src/plugin/parse-json-results/parse-json-results-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { QueryResult } from '../../driver/database-connection.js'
import { RootOperationNode } from '../../query-compiler/query-compiler.js'
import { isPlainObject, isString } from '../../util/object-utils.js'
import { freeze, isPlainObject, isString } from '../../util/object-utils.js'
import { UnknownRow } from '../../util/type-utils.js'
import {
KyselyPlugin,
Expand All @@ -9,6 +9,13 @@ import {
} from '../kysely-plugin.js'

export interface ParseJSONResultsPluginOptions {
/**
* A function that returns `true` if the given string is a JSON string.
*
* Defaults to a function that checks if the string starts and ends with `{}` or `[]`.
*/
isJSON?: (value: string) => boolean

/**
* When `'in-place'`, arrays' and objects' values are parsed in-place. This is
* the most time and space efficient option.
Expand All @@ -20,10 +27,27 @@ export interface ParseJSONResultsPluginOptions {
* Defaults to `'in-place'`.
*/
objectStrategy?: ObjectStrategy

/**
* The reviver function that will be passed to `JSON.parse`.
* See {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#the_reviver_parameter | The reviver parameter}.
*/
reviver?: (key: string, value: unknown, context?: any) => unknown

/**
* An array of keys that should not be parsed inside an object, even if they contain JSON strings.
*/
skipKeys?: string[]
}

type ObjectStrategy = 'in-place' | 'create'

type ProcessedParseJSONResultsPluginOptions = {
readonly [K in keyof ParseJSONResultsPluginOptions]-?: K extends 'skipKeys'
? Record<string, true>
: ParseJSONResultsPluginOptions[K]
}

/**
* Parses JSON strings in query results into JSON objects.
*
Expand All @@ -38,10 +62,21 @@ type ObjectStrategy = 'in-place' | 'create'
* ```
*/
export class ParseJSONResultsPlugin implements KyselyPlugin {
readonly #objectStrategy: ObjectStrategy
readonly #options: ProcessedParseJSONResultsPluginOptions

constructor(readonly opt: ParseJSONResultsPluginOptions = {}) {
this.#objectStrategy = opt.objectStrategy || 'in-place'
this.#options = freeze({
isJSON: opt.isJSON || maybeJson,
objectStrategy: opt.objectStrategy || 'in-place',
reviver: opt.reviver || ((_, value) => value),
skipKeys: (opt.skipKeys || []).reduce(
(acc, key) => {
acc[key] = true
return acc
},
{} as Record<string, true>,
),
})
}

// noop
Expand All @@ -54,41 +89,58 @@ export class ParseJSONResultsPlugin implements KyselyPlugin {
): Promise<QueryResult<UnknownRow>> {
return {
...args.result,
rows: parseArray(args.result.rows, this.#objectStrategy),
rows: parseArray(args.result.rows, this.#options),
}
}
}

function parseArray<T>(arr: T[], objectStrategy: ObjectStrategy): T[] {
const target = objectStrategy === 'create' ? new Array(arr.length) : arr
function parseArray<T>(
arr: T[],
options: ProcessedParseJSONResultsPluginOptions,
): T[] {
const target =
options.objectStrategy === 'create' ? new Array(arr.length) : arr

for (let i = 0; i < arr.length; ++i) {
target[i] = parse(arr[i], objectStrategy) as T
target[i] = parse(arr[i], options) as T
}

return target
}

function parse(obj: unknown, objectStrategy: ObjectStrategy): unknown {
if (isString(obj)) {
return parseString(obj)
function parse(
value: unknown,
options: ProcessedParseJSONResultsPluginOptions,
): unknown {
if (isString(value)) {
return parseString(value, options)
}

if (Array.isArray(obj)) {
return parseArray(obj, objectStrategy)
if (Array.isArray(value)) {
return parseArray(value, options)
}

if (isPlainObject(obj)) {
return parseObject(obj, objectStrategy)
if (isPlainObject(value)) {
return parseObject(value, options)
}

return obj
return value
}

function parseString(str: string): unknown {
if (maybeJson(str)) {
function parseString(
str: string,
options: ProcessedParseJSONResultsPluginOptions,
): unknown {
if (options.isJSON(str)) {
try {
return parse(JSON.parse(str), 'in-place')
return parse(
JSON.parse(str, (...args) => {
// prevent prototype pollution
if (args[0] === '__proto__') return
return options.reviver(...args)
}),
{ ...options, objectStrategy: 'in-place' },
)
} catch (err) {
// this catch block is intentionally empty.
}
Expand All @@ -98,17 +150,22 @@ function parseString(str: string): unknown {
}

function maybeJson(value: string): boolean {
return value.match(/^[\[\{]/) != null
return (
(value.startsWith('{') && value.endsWith('}')) ||
(value.startsWith('[') && value.endsWith(']'))
)
}

function parseObject(
obj: Record<string, unknown>,
objectStrategy: ObjectStrategy,
options: ProcessedParseJSONResultsPluginOptions,
): Record<string, unknown> {
const { objectStrategy, skipKeys } = options

const target = objectStrategy === 'create' ? {} : obj

for (const key in obj) {
target[key] = parse(obj[key], objectStrategy)
target[key] = skipKeys[key] ? obj[key] : parse(obj[key], options)
}

return target
Expand Down

0 comments on commit 3a1825e

Please sign in to comment.