All files / packages/query/src yql.ts

100% Statements 47/47
96.29% Branches 26/27
100% Functions 11/11
100% Lines 43/43

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178            23x 23x             613x         1263x 3x                   1260x 1x                 156x                   734x       734x           577x 577x                                 296x     296x 296x         457x                                 248x                           48x 48x 48x 161x 161x     48x                             505x 505x 1763x 1763x   1263x 1263x   1263x 802x 457x 301x   156x 156x 156x 156x       500x             589x   589x 385x     204x   204x    
import { type Value, fromJs } from '@ydbjs/value'
 
// ──────────────────────────────────────────────────────────────────────────
// Internal markers
// ──────────────────────────────────────────────────────────────────────────
 
const SymbolUnsafe = Symbol('unsafe')
const SymbolFragment = Symbol('fragment')
 
// ──────────────────────────────────────────────────────────────────────────
// Value helpers
// ──────────────────────────────────────────────────────────────────────────
 
function isObject(value: unknown): boolean {
	return typeof value === 'object' && value !== null && !Array.isArray(value)
}
 
// Enhanced validation with detailed error messages for better debugging
function validateValue(value: unknown, index: number): void {
	if (value === undefined) {
		throw new Error(
			`❌ Undefined value at position ${index} in yql template. ` +
				`This usually means:\n` +
				`  • A variable wasn't initialized\n` +
				`  • A function returned undefined\n` +
				`  • An object property doesn't exist\n` +
				`For intentional null database values, use YDB Optional type.`
		)
	}
 
	if (value === null) {
		throw new Error(
			`❌ Null value at position ${index} in yql template. ` +
				`JavaScript null is not directly supported in YDB queries.\n` +
				`For null database values, use YDB Optional type instead.`
		)
	}
}
 
function toYdbValue(value: any): Value {
	return isObject(value) && 'type' in value && 'kind' in (value as any)['type']
		? (value as Value)
		: fromJs(value as any)
}
 
// ──────────────────────────────────────────────────────────────────────────
// Raw SQL: identifiers and unsafe injection
// ──────────────────────────────────────────────────────────────────────────
 
export class UnsafeString extends String {
	[SymbolUnsafe] = true
}
 
export function unsafe(value: string | { toString(): string }) {
	return new UnsafeString(value.toString())
}
 
export function identifier(path: string) {
	// Escape backticks inside identifier by doubling them
	// Example: my`table -> my``table
	let escaped = path.replaceAll('`', '``')
	return unsafe('`' + escaped + '`')
}
 
// ──────────────────────────────────────────────────────────────────────────
// Composable fragments
// ──────────────────────────────────────────────────────────────────────────
 
/**
 * A composable, non-executable piece of a query: its own template text plus
 * bound values, which may themselves be other fragments, `UnsafeString`s, or
 * scalars. Parameter names are assigned only when the fragment is flattened
 * into a query, so fragments nest without parameter-name collisions.
 *
 * Create with {@link fragment} or {@link join}; splice into a `yql`/`fragment`
 * template like any other interpolated value.
 */
export class Fragment {
	[SymbolFragment] = true
 
	constructor(
		readonly strings: readonly string[],
		readonly values: readonly unknown[]
	) {}
}
 
function isFragment(value: unknown): value is Fragment {
	return isObject(value) && (value as any)[SymbolFragment] === true
}
 
/**
 * Create a composable query {@link Fragment} from a tagged template. Unlike the
 * query client's `sql\`\``, a fragment is not executable — it only nests into
 * another `yql`/`fragment` template or {@link join}.
 *
 * @example ```ts
 * const cond = fragment`${identifier('age')} > ${18}`
 * sql`SELECT * FROM users WHERE ${cond}`
 * ```
 */
export function fragment<P extends any[] = unknown[]>(
	strings: TemplateStringsArray,
	...values: P
): Fragment {
	return new Fragment(strings as readonly string[], values)
}
 
/**
 * Combine fragments into one, interleaving a separator between them. An empty
 * list yields an empty fragment; a single fragment is returned without a
 * separator. The separator is structural SQL — a string is inserted as raw text.
 *
 * @example ```ts
 * const where = join(conditions, ' AND ')
 * sql`SELECT * FROM users WHERE ${where}`
 * ```
 */
export function join(fragments: readonly Fragment[], separator: Fragment | string = ''): Fragment {
	let sep = typeof separator === 'string' ? unsafe(separator) : separator
	let values: unknown[] = []
	for (let i = 0; i < fragments.length; i++) {
		if (i > 0) values.push(sep)
		values.push(fragments[i])
	}
 
	return new Fragment(new Array(values.length + 1).fill(''), values)
}
 
// ──────────────────────────────────────────────────────────────────────────
// Query building
// ──────────────────────────────────────────────────────────────────────────
 
// Single recursive pass over the template tree. A shared counter assigns
// `$p0..$pN` in traversal order across nested fragments — no renumbering.
function flatten(
	strings: readonly string[],
	values: readonly unknown[],
	params: Record<string, Value>,
	counter: { n: number }
): string {
	let text = ''
	for (let i = 0; i < strings.length; i++) {
		text += strings[i]
		if (i >= values.length) continue
 
		let value = values[i]
		validateValue(value, i)
 
		if ((value as any)[SymbolUnsafe]) {
			text += (value as UnsafeString).toString()
		} else if (isFragment(value)) {
			text += flatten(value.strings, value.values, params, counter)
		} else {
			let name = `$p${counter.n}`
			params[name] = toYdbValue(value)
			text += name
			counter.n++
		}
	}
 
	return text
}
 
export function yql<P extends any[] = unknown[]>(
	strings: string | TemplateStringsArray,
	...values: P
): { text: string; params: Record<string, Value> } {
	let params: Record<string, Value> = Object.assign({}, null)
 
	if (typeof strings === 'string') {
		return { text: strings, params }
	}
 
	let text = flatten(strings, values, params, { n: 0 })
 
	return { text, params }
}