diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0acf326..be1be99 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,3 +28,6 @@ jobs: - name: Type check run: deno check src/index.ts + + - name: Test + run: deno test --allow-read --allow-write --allow-env diff --git a/build.config.ts b/build.config.ts index 5a608a5..c38ff99 100644 --- a/build.config.ts +++ b/build.config.ts @@ -14,8 +14,7 @@ export default defineBuildConfig({ clean: true, /** Path alias for imports (e.g. @root → src). */ alias: { - '@root': resolve(__dirname, 'src'), - '@interfaces': resolve(__dirname, 'src/interfaces') + '@app': resolve(__dirname, 'src') }, /** Rollup options: emit CJS and inline runtime deps. */ rollup: { diff --git a/deno.json b/deno.json index a64e6f9..508c7b2 100644 --- a/deno.json +++ b/deno.json @@ -48,7 +48,7 @@ "useTabs": false }, "lint": { - "include": ["src/"], + "include": ["src/", "tests/"], "rules": { "tags": ["fresh", "jsr", "jsx", "react", "recommended", "workspace"], "exclude": ["no-console", "no-external-import", "prefer-ascii", "prefer-primordials"] @@ -56,13 +56,18 @@ }, "lock": true, "nodeModulesDir": "auto", + "test": { + "include": ["tests/**/*.ts"], + "exclude": ["tests/**/*.d.ts"] + }, "tasks": { - "check": "deno fmt src/ && deno lint src/ && deno check src/" + "check": "deno fmt src/ && deno lint src/ && deno check src/", + "test": "deno fmt tests/ && deno lint tests/ && deno test --allow-read --allow-write --allow-env" }, "imports": { + "@std/assert": "jsr:@std/assert@^1.0.19", "@neabyte/jsonary": "./src/index.ts", - "@root/": "./src/", - "@interfaces/": "./src/interfaces/" + "@app/": "./src/" }, "publish": { "include": ["src/", "README.md", "LICENSE", "USAGE.md", "deno.json"] diff --git a/src/Constant.ts b/src/Constant.ts index f50722d..2ea95bc 100644 --- a/src/Constant.ts +++ b/src/Constant.ts @@ -1,10 +1,10 @@ -import type { QueryOperatorsType } from '@interfaces/index.ts' +import type * as Types from '@app/Types.ts' /** - * Query operator constants. + * Query operator constants * @description Centralized definitions for all supported query operators. */ -export const queryOperators: QueryOperatorsType = { +export const queryOperators: Types.QueryOperatorsType = { eq: '=', neq: '!=', gt: '>', @@ -17,8 +17,8 @@ export const queryOperators: QueryOperatorsType = { } as const /** - * Gets all operator values sorted by length (longest first). - * @description Used for parsing conditions with correct operator precedence. + * Get sorted operator values + * @description Sorts operators by length, longest first. * @returns Array of operator values sorted by length */ export function getOperatorsSorted(): string[] { diff --git a/src/Query.ts b/src/Query.ts index 0868c0f..ed0349d 100644 --- a/src/Query.ts +++ b/src/Query.ts @@ -1,86 +1,87 @@ -import type { JsonaryCondition, JsonaryParent } from '@interfaces/index.ts' -import { getOperatorsSorted, queryOperators } from '@root/Constant.ts' +import type * as Types from '@app/Types.ts' +import { getOperatorsSorted, queryOperators } from '@app/Constant.ts' /** - * Query builder for filtering and manipulating JSON data. - * @description Provides fluent interface for building complex queries with chaining operations. + * Query builder for records + * @description Fluent chaining for filters, updates, deletes. */ export class QueryBuilder { /** Cache for recently parsed conditions to improve performance */ - private static readonly conditionRecent: Map = new Map() + private static readonly recentConditionCache: Map = + new Map() /** Parent instance for synchronization */ - private readonly parent: JsonaryParent | undefined + private readonly parentDb: Types.JsonaryParent | undefined /** Array of applied conditions */ - private readonly conditions: JsonaryCondition[] = [] + private readonly conditions: Types.JsonaryCondition[] = [] /** Original data array reference */ - private readonly originalData: Record[] + private readonly originalRecords: Record[] /** Current filtered data array */ - private data: Record[] + private records: Record[] /** - * Creates a new QueryBuilder instance. - * @description Initializes the query builder with data and optional parent reference. + * Create QueryBuilder instance + * @description Stores records and optional parent sync. * @param data - Array of data records to query * @param parent - Optional parent instance for synchronization */ - constructor(data: Record[], parent?: JsonaryParent) { - this.originalData = data - this.data = [...data] - this.parent = parent + constructor(data: Record[], parent?: Types.JsonaryParent) { + this.originalRecords = data + this.records = [...data] + this.parentDb = parent } /** - * Gets the count of filtered records. - * @description Returns the number of records matching current filter conditions. + * Count filtered records + * @description Returns records matching current conditions. * @returns Number of matching records */ count(): number { - return this.data.length + return this.records.length } /** - * Deletes all filtered records. - * @description Removes all records matching current conditions from original data. + * Delete filtered records + * @description Removes matches from original records. * @returns Number of records deleted */ delete(): number { - const deletedCount: number = this.data.length - const itemsToDelete: Set> = new Set(this.data) - for (let i: number = this.originalData.length - 1; i >= 0; i--) { - if (itemsToDelete.has(this.originalData[i] as Record)) { - this.originalData.splice(i, 1) + const deletedCount: number = this.records.length + const recordsToDelete: Set> = new Set(this.records) + for (let i: number = this.originalRecords.length - 1; i >= 0; i--) { + if (recordsToDelete.has(this.originalRecords[i] as Record)) { + this.originalRecords.splice(i, 1) } } - this.data.length = 0 - this.parent?.syncFromQueryBuilder(this.originalData) + this.records.length = 0 + this.parentDb?.syncFromQueryBuilder(this.originalRecords) return deletedCount } /** - * Gets the first filtered record. - * @description Returns the first record matching current conditions or null if none found. + * Get first filtered record + * @description Returns first match, or null. * @returns First matching record or null */ first(): Record | null { - return this.data[0] ?? null + return this.records[0] ?? null } /** - * Gets all filtered records. - * @description Returns a copy of all records matching current conditions. + * Get filtered records + * @description Returns copy of matching records. * @returns Array of matching records */ get(): Record[] { - return [...this.data] + return [...this.records] } /** - * Updates all filtered records. - * @description Applies the provided data to all records matching current conditions. + * Update filtered records + * @description Applies fields to all matches. * @param data - Object containing fields to update */ update(data: Record): void { - this.data.forEach((item: Record) => { + this.records.forEach((item: Record) => { Object.keys(data).forEach((key: string) => { if (key.includes('.')) { this.setNestedValue(item, key, data[key]) @@ -89,24 +90,24 @@ export class QueryBuilder { } }) }) - this.parent?.syncFromQueryBuilder(this.originalData) + this.parentDb?.syncFromQueryBuilder(this.originalRecords) } /** - * Adds a filter condition to the query. - * @description Filters records based on string condition or function predicate. + * Add filter condition + * @description Filters by string or predicate. * @param condition - Query string or function to filter records * @returns QueryBuilder instance for chaining */ where(condition: string | ((item: Record) => boolean)): QueryBuilder { if (typeof condition === 'function') { - this.data = this.data.filter(condition) + this.records = this.records.filter(condition) } else { - const parsed: JsonaryCondition | null = this.parseCondition(condition) - if (parsed) { - this.conditions.push(parsed) - this.data = this.data.filter((item: Record) => - this.evaluateCondition(item, parsed) + const parsedCondition: Types.JsonaryCondition | null = this.parseCondition(condition) + if (parsedCondition) { + this.conditions.push(parsedCondition) + this.records = this.records.filter((item: Record) => + this.evaluateCondition(item, parsedCondition) ) } } @@ -114,17 +115,20 @@ export class QueryBuilder { } /** - * Evaluates a condition against a data item. - * @description Checks if an item matches the specified condition using appropriate operator. + * Evaluate condition against item + * @description Checks item match for given operator. * @param item - Data item to evaluate * @param condition - Condition to check against * @returns True if condition matches, false otherwise */ - private evaluateCondition(item: Record, condition: JsonaryCondition): boolean { - const { operator, value }: JsonaryCondition = condition + private evaluateCondition( + item: Record, + condition: Types.JsonaryCondition + ): boolean { + const { operator, value }: Types.JsonaryCondition = condition const fieldValue: unknown = this.getNestedValue(item, condition.field) - const op: JsonaryCondition['operator'] = operator - switch (op) { + const operatorToken: Types.JsonaryCondition['operator'] = operator + switch (operatorToken) { case queryOperators.eq: return fieldValue === value case queryOperators.neq: @@ -157,8 +161,8 @@ export class QueryBuilder { } /** - * Gets a nested value from an object using dot notation. - * @description Traverses object properties using dot-separated path. + * Get nested value by path + * @description Traverses dot-separated property path. * @param obj - Object to traverse * @param path - Dot-separated path to the property * @returns Value at the specified path or undefined @@ -167,53 +171,53 @@ export class QueryBuilder { if (!path.includes('.')) { return obj[path] } - let current: unknown = obj + let currentValue: unknown = obj const keys: string[] = path.split('.') for (let i: number = 0; i < keys.length; i++) { - if (current === null || current === undefined || typeof current !== 'object') { + if (currentValue === null || currentValue === undefined || typeof currentValue !== 'object') { return undefined } const key: string | undefined = keys[i] if (key === undefined) { return undefined } - current = (current as Record)[key] + currentValue = (currentValue as Record)[key] } - return current + return currentValue } /** - * Parses a string condition into structured format. - * @description Converts string-based conditions into JsonaryCondition objects. + * Parse condition string + * @description Converts string to condition object. * @param condition - String condition to parse * @returns Parsed condition object or null if invalid */ - private parseCondition(condition: string): JsonaryCondition | null { - if (QueryBuilder.conditionRecent.has(condition)) { - return QueryBuilder.conditionRecent.get(condition) ?? null + private parseCondition(condition: string): Types.JsonaryCondition | null { + if (QueryBuilder.recentConditionCache.has(condition)) { + return QueryBuilder.recentConditionCache.get(condition) ?? null } const operators: string[] = getOperatorsSorted() - for (const op of operators) { - const index: number = condition.indexOf(op) + for (const operatorToken of operators) { + const index: number = condition.indexOf(operatorToken) if (index !== -1) { - const value: string = condition.substring(index + op.length).trim() - const parsedValue: unknown = this.parseSpecialValue(this.stripQuotes(value)) - const result: JsonaryCondition = { + const rawValue: string = condition.substring(index + operatorToken.length).trim() + const parsedValue: unknown = this.parseSpecialValue(this.stripQuotes(rawValue)) + const result: Types.JsonaryCondition = { field: condition.substring(0, index).trim(), - operator: op as JsonaryCondition['operator'], + operator: operatorToken as Types.JsonaryCondition['operator'], value: parsedValue } - QueryBuilder.conditionRecent.set(condition, result) + QueryBuilder.recentConditionCache.set(condition, result) return result } } - QueryBuilder.conditionRecent.set(condition, null) + QueryBuilder.recentConditionCache.set(condition, null) return null } /** - * Parses special string values to their appropriate types. - * @description Converts string representations of special values to their actual types. + * Parse special value tokens + * @description Converts tokens to typed values. * @param value - Value to parse * @returns Parsed value in appropriate type */ @@ -236,34 +240,38 @@ export class QueryBuilder { } /** - * Sets a nested value in an object using dot notation. - * @description Creates nested objects as needed and sets the final value. + * Set nested value by path + * @description Creates objects and sets final value. * @param obj - Object to modify * @param path - Dot-separated path to the property * @param value - Value to set */ private setNestedValue(obj: Record, path: string, value: unknown): void { const keys: string[] = path.split('.') - let current: Record = obj + let currentObject: Record = obj for (let i: number = 0; i < keys.length - 1; i++) { const key: string | undefined = keys[i] if (key === undefined) { continue } - if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) { - current[key] = {} + if ( + !(key in currentObject) || + typeof currentObject[key] !== 'object' || + currentObject[key] === null + ) { + currentObject[key] = {} } - current = current[key] as Record + currentObject = currentObject[key] as Record } const lastKey: string | undefined = keys[keys.length - 1] if (lastKey !== undefined) { - current[lastKey] = value + currentObject[lastKey] = value } } /** - * Strips quotes from string values. - * @description Removes surrounding single or double quotes from string values. + * Strip surrounding quotes + * @description Removes wrapping single or double quotes. * @param value - String value to process * @returns Processed value with quotes removed if applicable */ diff --git a/src/interfaces/index.ts b/src/Types.ts similarity index 62% rename from src/interfaces/index.ts rename to src/Types.ts index dd53dd5..0d35a49 100644 --- a/src/interfaces/index.ts +++ b/src/Types.ts @@ -1,6 +1,6 @@ /** - * Query condition structure for filtering data. - * @description Defines the structure for parsing and evaluating query conditions. + * Query condition structure + * @description Parsed condition for record filtering. */ export interface JsonaryCondition { /** Field name to evaluate */ @@ -12,8 +12,8 @@ export interface JsonaryCondition { } /** - * Configuration options for Jsonary instance. - * @description Defines the file path where JSON data will be stored and managed. + * Jsonary configuration options + * @description Config for Jsonary file storage. */ export interface JsonaryOptions { /** File path to the JSON data file */ @@ -21,27 +21,27 @@ export interface JsonaryOptions { } /** - * Parent interface for synchronization with QueryBuilder. - * @description Defines the contract for parent instances to receive updates from QueryBuilder operations. + * Parent interface for QueryBuilder sync + * @description Contract for QueryBuilder sync updates. */ export interface JsonaryParent { /** - * Synchronizes updated data from QueryBuilder. - * @description Updates the parent instance with modified data after query operations. + * Synchronizes data from QueryBuilder + * @description Applies QueryBuilder changes to parent. * @param updatedData - The updated data array to sync */ syncFromQueryBuilder(updatedData: Record[]): void } /** - * Operator type for query conditions. - * @description Union type of all valid query operators. + * Query operator type + * @description Union of valid operator tokens. */ export type QueryOperator = QueryOperatorsType[keyof QueryOperatorsType] /** - * Available query operators for filtering data. - * @description Defines all supported comparison operators used in query conditions. + * Available query operators + * @description Operator tokens supported in conditions. */ export type QueryOperatorsType = { /** Equality operator for exact matches */ diff --git a/src/index.ts b/src/index.ts index 28fa72d..6b79726 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,11 @@ -import type { JsonaryCondition, JsonaryOptions } from '@interfaces/index.ts' -import { QueryBuilder } from '@root/Query.ts' +import type * as Types from '@app/Types.ts' +import { QueryBuilder } from '@app/Query.ts' import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' /** - * JSON database management class. - * @description Provides CRUD operations and querying capabilities for JSON data stored in files. + * JSON database manager + * @description CRUD and queries for JSON file. */ export default class Jsonary { /** File path where JSON data is stored */ @@ -14,18 +14,18 @@ export default class Jsonary { private data: Record[] /** - * Creates a new Jsonary instance. - * @description Initializes the database with the specified file path and loads existing data. + * Create Jsonary instance + * @description Resolves path and loads existing data. * @param options - Configuration options containing the file path */ - constructor(options: JsonaryOptions) { + constructor(options: Types.JsonaryOptions) { this.filePath = resolve(options.path) this.data = this.loadData() } /** - * Clears all data from the database. - * @description Removes all records and saves the empty state to file. + * Clear all records + * @description Empties data and saves file. */ clear(): void { this.data = [] @@ -33,31 +33,31 @@ export default class Jsonary { } /** - * Deletes records matching the specified condition. - * @description Removes all records that match the condition and saves changes to file. + * Delete matching records + * @description Removes matches and saves to file. * @param condition - Query string or function to determine which records to delete * @returns Number of records deleted */ deleteWhere(condition: string | ((item: Record) => boolean)): number { - const initialLength: number = this.data.length + const initialRecordCount: number = this.data.length if (typeof condition === 'function') { this.data = this.data.filter((item: Record) => !condition(item)) } else { const builder: QueryBuilder = new QueryBuilder(this.data) - const parsed: JsonaryCondition | null = builder['parseCondition'](condition) - if (parsed) { + const parsedCondition: Types.JsonaryCondition | null = builder['parseCondition'](condition) + if (parsedCondition) { this.data = this.data.filter( - (item: Record) => !builder['evaluateCondition'](item, parsed) + (item: Record) => !builder['evaluateCondition'](item, parsedCondition) ) } } this.saveData() - return initialLength - this.data.length + return initialRecordCount - this.data.length } /** - * Retrieves all records from the database. - * @description Returns a copy of all data records. + * Get all records + * @description Returns copy of all records. * @returns Array of all records */ get(): Record[] { @@ -65,8 +65,8 @@ export default class Jsonary { } /** - * Inserts a single record into the database. - * @description Adds a new record and saves changes to file. + * Insert one record + * @description Appends record and saves to file. * @param item - Record to insert */ insert(item: Record): void { @@ -75,8 +75,8 @@ export default class Jsonary { } /** - * Inserts multiple records into the database. - * @description Adds multiple records at once and saves changes to file. + * Insert many records + * @description Appends records and saves to file. * @param items - Array of records to insert */ insertMany(items: Record[]): void { @@ -85,16 +85,16 @@ export default class Jsonary { } /** - * Reloads data from the file. - * @description Refreshes the internal data from the stored JSON file. + * Reload data file + * @description Loads current file into memory. */ reload(): void { this.data = this.loadData() } /** - * Synchronizes data from QueryBuilder. - * @description Updates internal data with changes made through QueryBuilder operations. + * Sync from QueryBuilder + * @description Applies updated record array then saves. * @param updatedData - Updated data array from QueryBuilder */ syncFromQueryBuilder(updatedData: Record[]): void { @@ -103,8 +103,8 @@ export default class Jsonary { } /** - * Updates records matching the specified condition. - * @description Modifies fields of records that match the condition and saves changes to file. + * Update matching records + * @description Updates matches and saves to file. * @param condition - Query string or function to determine which records to update * @param data - Object containing fields to update * @returns Number of records updated @@ -120,7 +120,7 @@ export default class Jsonary { shouldUpdate = condition(item) } else { const builder: QueryBuilder = new QueryBuilder([item]) - const parsed: JsonaryCondition | null = builder['parseCondition'](condition) + const parsed: Types.JsonaryCondition | null = builder['parseCondition'](condition) if (parsed) { shouldUpdate = builder['evaluateCondition'](item, parsed) } @@ -141,8 +141,8 @@ export default class Jsonary { } /** - * Creates a QueryBuilder for filtering data. - * @description Returns a QueryBuilder instance to perform chained query operations. + * Start query builder + * @description Returns builder for chained operations. * @param condition - Initial condition to filter data * @returns QueryBuilder instance for chained operations */ @@ -151,8 +151,8 @@ export default class Jsonary { } /** - * Loads data from the JSON file. - * @description Reads and parses JSON data from the configured file path. + * Load data from file + * @description Reads JSON and normalizes array root. * @returns Array of records or empty array if file doesn't exist or is invalid */ private loadData(): Record[] { @@ -160,60 +160,64 @@ export default class Jsonary { if (!existsSync(this.filePath)) { return [] } - const content: string = readFileSync(this.filePath, 'utf-8') - const parsed: unknown = JSON.parse(content) - if (Array.isArray(parsed)) { - return parsed as Record[] + const fileContent: string = readFileSync(this.filePath, 'utf-8') + const parsedJson: unknown = JSON.parse(fileContent) + if (Array.isArray(parsedJson)) { + return parsedJson as Record[] } - return [parsed as Record] + return [parsedJson as Record] } catch { return [] } } /** - * Saves data to the JSON file. - * @description Writes the current data array to the configured file path. + * Save data to file + * @description Writes current records as JSON. * @throws {Error} When file write operation fails */ private saveData(): void { try { - const content: string = JSON.stringify(this.data, null, 2) - writeFileSync(this.filePath, content, 'utf-8') + const fileContent: string = JSON.stringify(this.data, null, 2) + writeFileSync(this.filePath, fileContent, 'utf-8') } catch (error) { throw new Error(String(error)) } } /** - * Sets a nested value in an object using dot notation. - * @description Creates nested objects as needed and sets the final value. + * Set nested object value + * @description Creates objects and sets final value. * @param obj - Object to modify * @param path - Dot-separated path to the property * @param value - Value to set */ private setNestedValue(obj: Record, path: string, value: unknown): void { const keys: string[] = path.split('.') - let current: Record = obj + let currentObject: Record = obj for (let i: number = 0; i < keys.length - 1; i++) { const key: string | undefined = keys[i] if (key === undefined) { continue } - if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) { - current[key] = {} + if ( + !(key in currentObject) || + typeof currentObject[key] !== 'object' || + currentObject[key] === null + ) { + currentObject[key] = {} } - current = current[key] as Record + currentObject = currentObject[key] as Record } const lastKey: string | undefined = keys[keys.length - 1] if (lastKey !== undefined) { - current[lastKey] = value + currentObject[lastKey] = value } } } /** - * Exports interfaces for type checking. - * @description Provides type definitions for Jsonary operations and conditions. + * Export public types + * @description Re-exports Types module for consumers. */ -export * from '@interfaces/index.ts' +export type * as Types from '@app/Types.ts' diff --git a/tests/jsonary.test.ts b/tests/jsonary.test.ts new file mode 100644 index 0000000..5852961 --- /dev/null +++ b/tests/jsonary.test.ts @@ -0,0 +1,313 @@ +import { assert, assertEquals } from '@std/assert' +import Jsonary from '@neabyte/jsonary' + +function assertRecord(value: unknown, message: string): Record { + assert(typeof value === 'object' && value !== null, message) + return value as Record +} + +function assertIndex(value: T | undefined, message: string): T { + assert(value !== undefined, message) + return value +} + +async function withTempDb( + initialData: Record[], + fn: (db: Jsonary, filePath: string) => Promise | T +): Promise { + const dir = await Deno.makeTempDir() + const filePath = `${dir}/data.json` + await Deno.writeTextFile(filePath, JSON.stringify(initialData, null, 2)) + + try { + const db = new Jsonary({ path: filePath }) + return await fn(db, filePath) + } finally { + await Deno.remove(dir, { recursive: true }) + } +} + +async function withTempDbRoot( + root: unknown, + fn: (db: Jsonary, filePath: string) => Promise | T +): Promise { + const dir = await Deno.makeTempDir() + const filePath = `${dir}/data.json` + await Deno.writeTextFile(filePath, JSON.stringify(root, null, 2)) + try { + const db = new Jsonary({ path: filePath }) + return await fn(db, filePath) + } finally { + await Deno.remove(dir, { recursive: true }) + } +} + +Deno.test('Jsonary - accepts non-array root JSON (wraps into single record list)', async () => { + await withTempDbRoot({ name: 'Root' }, (db) => { + assertEquals(db.get().length, 1) + const record = assertIndex(db.get()[0], 'Expected single wrapped record') + assertEquals(record['name'], 'Root') + }) +}) + +Deno.test('Jsonary - deleteWhere supports string and function conditions', async () => { + await withTempDb( + [ + { name: 'A', age: 10 }, + { name: 'B', age: 17 }, + { name: 'C', age: 20 } + ], + (db) => { + const deletedString = db.deleteWhere('age < 18') + assertEquals(deletedString, 2) + assertEquals(db.get().length, 1) + const remaining = assertIndex(db.get()[0], 'Expected one remaining record') + assertEquals(remaining['name'], 'C') + + const deletedFn = db.deleteWhere((item) => { + const age = item['age'] + return typeof age === 'number' && age < 25 + }) + assertEquals(deletedFn, 1) + assertEquals(db.get().length, 0) + } + ) +}) + +Deno.test('Jsonary - invalid string condition does not filter results', async () => { + await withTempDb( + [ + { name: 'A', age: 1 }, + { name: 'B', age: 2 } + ], + (db) => { + const result = db.where('age').get() + assertEquals(result.length, 2) + } + ) +}) + +Deno.test('Jsonary - insert and get persist to file', async () => { + await withTempDb([], async (db, filePath) => { + db.insert({ name: 'John', age: 30 }) + + const all = db.get() + assertEquals(all.length, 1) + const first = assertIndex(all[0], 'Expected at least one record') + assertEquals(first['name'], 'John') + assertEquals(first['age'], 30) + + const parsed = JSON.parse(await Deno.readTextFile(filePath)) as unknown + assert(Array.isArray(parsed), 'Expected JSON array in file') + const firstInFile = assertIndex((parsed as Record[])[0], 'File array empty') + assertEquals(firstInFile['name'], 'John') + }) +}) + +Deno.test('Jsonary - insertMany persists and clear wipes the file', async () => { + await withTempDb([], async (db, filePath) => { + db.insertMany([ + { name: 'Jane', age: 25 }, + { name: 'Bob', age: 35 } + ]) + + assertEquals(db.get().length, 2) + + db.clear() + assertEquals(db.get().length, 0) + + const parsed = JSON.parse(await Deno.readTextFile(filePath)) as unknown + assert(Array.isArray(parsed), 'Expected JSON array in file') + assertEquals((parsed as unknown[]).length, 0) + }) +}) + +Deno.test('Jsonary - query operators (quoted values and contains/starts/ends)', async () => { + await withTempDb( + [ + { name: 'John', age: 31 }, + { name: 'Johnny', age: 20 }, + { name: 'Alice', age: 18 } + ], + (db) => { + assertEquals(db.where('name = "John"').first()?.['age'], 31) + + const contains = db.where('name contains "ohn"').get() + assertEquals(contains.length, 2) + + const starts = db.where('name startsWith "John"').get() + assertEquals(starts.length, 2) + + const ends = db.where('name endsWith "lice"').get() + assertEquals(ends.length, 1) + const endRecord = assertIndex(ends[0], 'Expected one endsWith match') + assertEquals(endRecord['name'], 'Alice') + + const gte = db.where('age >= 20').count() + assertEquals(gte, 2) + + const lte = db.where('age <= 20').count() + assertEquals(lte, 2) + } + ) +}) + +Deno.test('Jsonary - query operators neq/gt/lt plus single quotes', async () => { + await withTempDb( + [ + { name: 'John', age: 21 }, + { name: 'Jane', age: 20 }, + { name: 'Alice', age: 19 } + ], + (db) => { + assertEquals(db.where("name = 'John'").count(), 1) + assertEquals(db.where('age != 20').count(), 2) + assertEquals(db.where('age > 20').count(), 1) + assertEquals(db.where('age < 20').count(), 1) + assertEquals(db.where('age >= 20').count(), 2) + assertEquals(db.where('age <= 21').count(), 3) + } + ) +}) + +Deno.test('Jsonary - query supports null and undefined values', async () => { + await withTempDb([{ name: 'A', value: null }, { name: 'B' }], (db) => { + assertEquals(db.where('value = null').count(), 1) + assertEquals(db.where('value = null').first()?.['name'], 'A') + + assertEquals(db.where('value = undefined').count(), 1) + assertEquals(db.where('value = undefined').first()?.['name'], 'B') + + assertEquals(db.where('value != undefined').count(), 1) + }) +}) + +Deno.test('Jsonary - reload picks up external file changes', async () => { + await withTempDb([], async (_db, filePath) => { + const db = new Jsonary({ path: filePath }) + assertEquals(db.get().length, 0) + await Deno.writeTextFile(filePath, JSON.stringify([{ id: 1 }, { id: 2 }], null, 2)) + db.reload() + const all = db.get() + assertEquals(all.length, 2) + const first = assertIndex(all[0], 'Expected first record') + const second = assertIndex(all[1], 'Expected second record') + assertEquals(first['id'], 1) + assertEquals(second['id'], 2) + }) +}) + +Deno.test('Jsonary - updateWhere creates nested objects for missing paths', async () => { + await withTempDb([{ name: 'John', age: 30 }], (db) => { + const updated = db.updateWhere('name = John', { 'profile.settings.theme': 'dark' }) + assertEquals(updated, 1) + + const record = db.where('profile.settings.theme = dark').first() + assert(record !== null, 'Expected a matching record') + + const profile = assertRecord(record?.['profile'], 'Expected profile object') + const settings = assertRecord(profile['settings'], 'Expected settings object') + assertEquals(settings['theme'], 'dark') + }) +}) + +Deno.test('Jsonary - updateWhere supports function predicate', async () => { + await withTempDb( + [ + { name: 'A', age: 10 }, + { name: 'B', age: 17 }, + { name: 'C', age: 20 } + ], + (db) => { + const updated = db.updateWhere( + (item) => { + const age = item['age'] + return typeof age === 'number' && age < 18 + }, + { status: 'minor' } + ) + + assertEquals(updated, 2) + assertEquals(db.where('status = minor').count(), 2) + assertEquals( + db + .where('status = minor') + .get() + .some((item: Record) => item['name'] === 'C'), + false + ) + } + ) +}) + +Deno.test('Jsonary - updateWhere supports nested dot-path keys', async () => { + await withTempDb([{ name: 'Jane', age: 25, profile: { role: 'admin', active: false } }], (db) => { + const updated = db.updateWhere('profile.role = admin', { + 'profile.active': true, + 'profile.verified': true + }) + + assertEquals(updated, 1) + + const admin = db.where('profile.active = true').first() + assert(admin !== null, 'Expected a matching admin record') + const profile = assertRecord(admin?.['profile'], 'Expected profile object') + assertEquals(profile['active'], true) + assertEquals(profile['role'], 'admin') + assertEquals(profile['verified'], true) + }) +}) + +Deno.test('Jsonary - where chaining update/count/first/delete', async () => { + await withTempDb( + [ + { name: 'John', age: 30, profile: { role: 'admin', active: true } }, + { name: 'Jane', age: 25, profile: { role: 'user', active: true } }, + { name: 'Bob', age: 40, profile: { role: 'admin', active: false } } + ], + (db) => { + db.where('age >= 25').where('profile.role = admin').update({ 'profile.verified': true }) + + const verifiedAdmins = db.where('profile.verified = true').get() + assertEquals(verifiedAdmins.length, 2) + const verifiedAdmin = assertIndex(verifiedAdmins[0], 'Expected one verified admin record') + assertEquals(verifiedAdmin['name'], 'John') + + const adminCount = db.where('profile.role = admin').count() + assertEquals(adminCount, 2) + + const firstAdmin = db.where('profile.role = admin').first() + assert(firstAdmin !== null, 'Expected a first admin record') + assertEquals(firstAdmin?.['name'], 'John') + + const deleted = db.where('profile.active = false').delete() + assertEquals(deleted, 1) + assertEquals(db.where('profile.active = false').count(), 0) + } + ) +}) + +Deno.test('Jsonary - where supports function predicate chaining', async () => { + await withTempDb( + [ + { name: 'John', age: 31 }, + { name: 'Johnny', age: 20 }, + { name: 'Alice', age: 18 } + ], + (db) => { + const result = db + .where((item) => { + const age = item['age'] + return typeof age === 'number' && age >= 20 + }) + .where('name contains "ohn"') + .get() + + assertEquals(result.length, 2) + assertEquals( + result.some((item: Record) => item['name'] === 'Alice'), + false + ) + } + ) +})