diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..fbf81b93 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.exclude": { + "**/node_modules": false, + } +} \ No newline at end of file diff --git a/exercises/exercise-00/index.ts b/exercises/exercise-00/index.ts index 4b815840..fc68abe8 100644 --- a/exercises/exercise-00/index.ts +++ b/exercises/exercise-00/index.ts @@ -26,7 +26,13 @@ Run this exercise: */ -const users: unknown[] = [ +interface Users { + name: string, + age: number, + occupation: string +} + +const users: Users[] = [ { name: 'Max Mustermann', age: 25, @@ -39,7 +45,7 @@ const users: unknown[] = [ } ]; -function logPerson(user: unknown) { +function logPerson(user: Users) { console.log(` - ${chalk.green(user.name)}, ${user.age}`); } diff --git a/exercises/exercise-01/index.ts b/exercises/exercise-01/index.ts index 6970e2a7..95d75f70 100644 --- a/exercises/exercise-01/index.ts +++ b/exercises/exercise-01/index.ts @@ -40,7 +40,9 @@ interface Admin { role: string; } -const persons: User[] /* <- Person[] */ = [ +type Person = User | Admin; + +const persons: Person[] = [ { name: 'Max Mustermann', age: 25, @@ -63,7 +65,7 @@ const persons: User[] /* <- Person[] */ = [ } ]; -function logPerson(user: User) { +function logPerson(user: Person) { console.log(` - ${chalk.green(user.name)}, ${user.age}`); } diff --git a/exercises/exercise-02/index.ts b/exercises/exercise-02/index.ts index c28bfa4e..0e8ff05d 100644 --- a/exercises/exercise-02/index.ts +++ b/exercises/exercise-02/index.ts @@ -65,7 +65,7 @@ const persons: Person[] = [ function logPerson(person: Person) { let additionalInformation: string; - if (person.role) { + if ('role' in person) { additionalInformation = person.role; } else { additionalInformation = person.occupation; diff --git a/exercises/exercise-03/index.ts b/exercises/exercise-03/index.ts index 6a7c911c..f0aa6a42 100644 --- a/exercises/exercise-03/index.ts +++ b/exercises/exercise-03/index.ts @@ -48,11 +48,11 @@ const persons: Person[] = [ { type: 'admin', name: 'Bruce Willis', age: 64, role: 'World saver' } ]; -function isAdmin(person: Person) { +function isAdmin(person: Person): person is Admin{ return person.type === 'admin'; } -function isUser(person: Person) { +function isUser(person: Person): person is User { return person.type === 'user'; } diff --git a/exercises/exercise-04/index.ts b/exercises/exercise-04/index.ts index fd114b0e..964665c2 100644 --- a/exercises/exercise-04/index.ts +++ b/exercises/exercise-04/index.ts @@ -47,6 +47,8 @@ interface Admin { type Person = User | Admin; +type PartialUser = Partial; + const persons: Person[] = [ { type: 'user', name: 'Max Mustermann', age: 25, occupation: 'Chimney sweep' }, { @@ -95,7 +97,7 @@ function logPerson(person: Person) { console.log(` - ${chalk.green(person.name)}, ${person.age}, ${additionalInformation}`); } -function filterUsers(persons: Person[], criteria: User): User[] { +function filterUsers(persons: Person[], criteria: PartialUser): User[] { return persons.filter(isUser).filter((user) => { let criteriaKeys = Object.keys(criteria) as (keyof User)[]; return criteriaKeys.every((fieldName) => { diff --git a/exercises/exercise-05/index.ts b/exercises/exercise-05/index.ts index 45e349ae..d93aa38e 100644 --- a/exercises/exercise-05/index.ts +++ b/exercises/exercise-05/index.ts @@ -49,6 +49,8 @@ interface Admin { type Person = User | Admin; +type FilterCriteria = Partial>; + const persons: Person[] = [ { type: 'user', name: 'Max Mustermann', age: 25, occupation: 'Chimney sweep' }, { type: 'admin', name: 'Jane Doe', age: 32, role: 'Administrator' }, @@ -64,11 +66,17 @@ function logPerson(person: Person) { ); } -function filterPersons(persons: Person[], personType: string, criteria: unknown): unknown[] { +function getObjectKeys(obj: O): (keyof O)[] { + return Object.keys(obj) as (keyof O)[]; +} + +function filterPersons(persons: Person[], personType: 'user', criteria: FilterCriteria): User[]; +function filterPersons(persons: Person[], personType: 'admin', criteria: FilterCriteria): Admin[]; +function filterPersons(persons: Person[], personType: 'user' | 'admin', criteria: FilterCriteria): Person[] { return persons .filter((person) => person.type === personType) .filter((person) => { - let criteriaKeys = Object.keys(criteria) as (keyof Person)[]; + let criteriaKeys = getObjectKeys(criteria); return criteriaKeys.every((fieldName) => { return person[fieldName] === criteria[fieldName]; }); diff --git a/exercises/exercise-06/index.ts b/exercises/exercise-06/index.ts index cf186c13..b0ae686c 100644 --- a/exercises/exercise-06/index.ts +++ b/exercises/exercise-06/index.ts @@ -88,7 +88,7 @@ const users: User[] = [ } ]; -function swap(v1, v2) { +function swap(v1: T1, v2: T2) : [T2, T1] { return [v2, v1]; } diff --git a/exercises/exercise-07/index.ts b/exercises/exercise-07/index.ts index 7b77e571..4578f5ae 100644 --- a/exercises/exercise-07/index.ts +++ b/exercises/exercise-07/index.ts @@ -41,7 +41,7 @@ interface Admin { role: string; } -type PowerUser = unknown; +type PowerUser = Omit & { type: 'powerUser' }; type Person = User | Admin | PowerUser; @@ -99,4 +99,4 @@ console.log(chalk.yellow('Power users:')); persons.filter(isPowerUser).forEach(logPerson); // In case if you are stuck: -// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#predefined-conditional-types +// https://www.typescriptlang.org/docs/handbook/utility-types.html diff --git a/exercises/exercise-08/index.ts b/exercises/exercise-08/index.ts index 1638eddf..ae02accf 100644 --- a/exercises/exercise-08/index.ts +++ b/exercises/exercise-08/index.ts @@ -63,50 +63,39 @@ const users: User[] = [ { type: 'user', name: 'Kate Müller', age: 23, occupation: 'Astronaut' } ]; -type AdminsApiResponse = ( +type ApiResponse = ( { status: 'success'; - data: Admin[]; + data: T; } | { status: 'error'; error: string; } -); +) -function requestAdmins(callback: (response: AdminsApiResponse) => void) { +function requestAdmins(callback: (response: ApiResponse) => void) { callback({ status: 'success', data: admins }); } -type UsersApiResponse = ( - { - status: 'success'; - data: User[]; - } | - { - status: 'error'; - error: string; - } -); - -function requestUsers(callback: (response: UsersApiResponse) => void) { +function requestUsers(callback: (response: ApiResponse) => void) { callback({ status: 'success', data: users }); } -function requestCurrentServerTime(callback: (response: unknown) => void) { +function requestCurrentServerTime(callback: (response: ApiResponse) => void) { callback({ status: 'success', data: Date.now() }); } -function requestCoffeeMachineQueueLength(callback: (response: unknown) => void) { +function requestCoffeeMachineQueueLength(callback: (response: ApiResponse) => void) { callback({ status: 'error', error: 'Numeric value has exceeded Number.MAX_SAFE_INTEGER.' diff --git a/exercises/exercise-09/index.ts b/exercises/exercise-09/index.ts index 66607dfc..a6d96b4e 100644 --- a/exercises/exercise-09/index.ts +++ b/exercises/exercise-09/index.ts @@ -71,8 +71,19 @@ type ApiResponse = ( } ); -function promisify(arg: unknown): unknown { - return null; +type PromisifyOldDefinition = (callback: (response: ApiResponse) => void) => void; +type PromisifyNewDefinition = () => Promise; + +function promisify(oldFunction: PromisifyOldDefinition): PromisifyNewDefinition { + return () => new Promise((resolve, reject) => { + oldFunction((response) => { + if (response.status === 'success') { + resolve(response.data) + } else { + reject(response.error) + } + }); + }); } const oldApi = { diff --git a/exercises/exercise-10/declarations/str-utils/index.d.ts b/exercises/exercise-10/declarations/str-utils/index.d.ts index 53567493..61d20077 100644 --- a/exercises/exercise-10/declarations/str-utils/index.d.ts +++ b/exercises/exercise-10/declarations/str-utils/index.d.ts @@ -1,4 +1,7 @@ declare module 'str-utils' { - // export const ... - // export function ... + export function strToLower(str: string): string; + export function strToUpper(str: string): string; + export function strReverse(str: string): string; + export function strInvertCase(str: string): string; + export function strRandomize(str: string): string; } diff --git a/exercises/exercise-11/declarations/stats/index.d.ts b/exercises/exercise-11/declarations/stats/index.d.ts index da99fbfc..f8747df8 100644 --- a/exercises/exercise-11/declarations/stats/index.d.ts +++ b/exercises/exercise-11/declarations/stats/index.d.ts @@ -1,3 +1,13 @@ declare module 'stats' { - export function getMaxIndex(input: unknown, comparator: unknown): unknown; + type Comparator = (a: I, b: I) => number; + type StatIndexFunction = (input: I[], comparator: Comparator) => number; + type StatElementFunction = (input: I[], comparator: Comparator) => I; + + export const getMaxIndex: StatIndexFunction; + export const getMinIndex: StatIndexFunction; + export const getMedianIndex: StatIndexFunction; + export const getMaxElement: StatElementFunction; + export const getMinElement: StatElementFunction; + export const getMedianElement: StatElementFunction; + export const getAverageValue: (items: I[], getValue: (item: I) => O) => O; } diff --git a/exercises/exercise-11/index.ts b/exercises/exercise-11/index.ts index c7db9a42..993bfecf 100644 --- a/exercises/exercise-11/index.ts +++ b/exercises/exercise-11/index.ts @@ -42,7 +42,7 @@ Exercise: Provide type declaration for that module in: declarations/stats/index.d.ts -Higher difficulty bonus excercise: +Higher difficulty bonus exercise: Avoid duplicates of type declarations. diff --git a/exercises/exercise-11/node_modules/stats/README.md b/exercises/exercise-11/node_modules/stats/README.md index 94b06f77..72af0438 100644 --- a/exercises/exercise-11/node_modules/stats/README.md +++ b/exercises/exercise-11/node_modules/stats/README.md @@ -38,7 +38,7 @@ It's already installed in your `node_modules`, all good. * Returns the median element in the array. * Returns `null` if array is empty. -### `getAverageValue(input, comparator)` +### `getAverageValue(input, getValue)` * Returns the average numeric value of the array. * Returns `null` if array is empty. diff --git a/exercises/exercise-12/module-augmentations/date-wizard/index.ts b/exercises/exercise-12/module-augmentations/date-wizard/index.ts index 8355c24a..23e942e0 100644 --- a/exercises/exercise-12/module-augmentations/date-wizard/index.ts +++ b/exercises/exercise-12/module-augmentations/date-wizard/index.ts @@ -2,5 +2,10 @@ import 'date-wizard'; declare module 'date-wizard' { - // Add your module extensions here. + interface DateDetails { + hours: number; + minutes: number; + seconds: number; + } + export function pad(num: number): string; } diff --git a/exercises/exercise-13/database.ts b/exercises/exercise-13/database.ts index 9f938b85..a40488c0 100644 --- a/exercises/exercise-13/database.ts +++ b/exercises/exercise-13/database.ts @@ -1,13 +1,178 @@ +import {readFile} from 'fs'; + + +type FieldQuery = + | {$eq: FT} + | {$lt: FT} + | {$gt: FT} + | {$in: FT[]} + +type Query = {[K in keyof T]?: FieldQuery} & { + $text?: string; + $and?: Query[]; + $or?: Query[]; +} + +interface Documents { + [key: string]: boolean; +} + +function intersectSearchResults(documents: Documents[]) { + const results: Documents = {} + if (documents.length === 0) { + return results + } + + for(let key of Object.keys(documents[0])) { + let keep = true; + for (let i = 0; i < documents.length; i++) { + if (!documents[i][key]) { + keep = false; + break; + } + } + + if (keep) { + results[key] = true; + } + } + + return results; + +} + +function mergeSearchResults(documents: Documents[]) { + const results: Documents = {}; + for (const document of documents) { + for (const key of Object.keys(document)) { + results[key] = true + } + } + + return results +} + export class Database { protected filename: string; - protected fullTextSearchFieldNames: unknown[]; + protected fullTextSearchFieldNames: (keyof T)[]; + protected getDocumentsPromise: Promise | null = null; + protected getFullTextSearchIndexPromise: Promise | null = null; - constructor(filename: string, fullTextSearchFieldNames) { + constructor(filename: string, fullTextSearchFieldNames: (keyof T)[]) { this.filename = filename; this.fullTextSearchFieldNames = fullTextSearchFieldNames; } - async find(query): Promise { - return []; + async find(query: Query): Promise { + const documents = await this.getDocuments(); + return Object.keys(await this.findMatchingDocuments(query)) + .map(Number) + .map((index) => documents[index]) + } + + protected getDocuments() { + return this.getDocumentsPromise || (new Promise((resolve, reject) => { + readFile(this.filename, 'utf8', (error, data) => { + if (error) { + reject(error); + return; + } + resolve( + data + .trim() + .split('\n') + .filter((line) => line[0] === 'E') + .map((line) => JSON.parse(line.substr(1))) + ); + }); + })) + } + + protected getFullTextSearchIndex() { + return this.getFullTextSearchIndexPromise || this.getDocuments().then((documents) => { + const fullTextSearchIndex = new FullTextSearchIndex(); + documents.forEach((document, index) => { + fullTextSearchIndex.addDocument( + index, + this.fullTextSearchFieldNames.map((key) => String(document[key])) + ); + }) + return fullTextSearchIndex; + }) + } + + protected async getMatchingDocumentsIds(comparator: (document: T) => boolean) { + const result: Documents = {}; + const documents = await this.getDocuments(); + for(let i = 0; i < documents.length; i++) { + if (comparator(documents[i])) { + result[i] = true; + } + } + return result; + } + + protected async findMatchingDocuments(query: Query) : Promise{ + const result: Documents[] = []; + + for (const key of Object.keys(query) as (keyof Query)[]) { + if (key === '$text') { + result.push((await this.getFullTextSearchIndex()).search(query.$text!)) + } else if (key === '$and') { + // 链式调用 + result.push( + intersectSearchResults(await Promise.all(query.$and!.map(this.findMatchingDocuments, this))) + ); + } else if (key === '$or') { + result.push(mergeSearchResults(await Promise.all(query.$or!.map(this.findMatchingDocuments, this)))); + } else { + const fieldQuery = query[key] as FieldQuery + if ('$eq' in fieldQuery) { + result.push(await this.getMatchingDocumentsIds((document) => document[key] === fieldQuery.$eq)) + } else if ('$gt' in fieldQuery) { + result.push(await this.getMatchingDocumentsIds((document) => Number(document[key]) > Number(fieldQuery.$gt))) + } else if ('$lt' in fieldQuery) { + result.push(await this.getMatchingDocumentsIds((document) => Number(document[key]) < Number(fieldQuery.$lt))) + } else if ('$in' in fieldQuery) { + const index: {[key: string] : boolean} = {} + for(const value of fieldQuery.$in) { + index[String(value)] = true; + } + result.push(await this.getMatchingDocumentsIds((document) => index.hasOwnProperty(String(document[key])))) + } else { + throw new Error('Incorrect query') + } + } + } + + return intersectSearchResults(result) } } + +class FullTextSearchIndex { + protected wordsToDocuments: {[words: string]: Documents} = {} + + protected breakTextIntowords (text: string) { + return text.toLowerCase().replace(/\W+/g, ' ').trim().split(' '); + } + + + addDocument(documentIndex: number, texts: string[]) { + for(const text of texts) { + const words = this.breakTextIntowords(text); + for(let word of words) { + this.wordsToDocuments[word] = this.wordsToDocuments[word] || {}; + this.wordsToDocuments[word][documentIndex] = true; + } + } + } + + search(query: string): Documents { + return intersectSearchResults( + this.breakTextIntowords(query) + .map((word) => this.wordsToDocuments[word]) + .filter(Boolean) + ) + } +} + diff --git a/exercises/exercise-14/database.ts b/exercises/exercise-14/database.ts index 7facb269..07930802 100644 --- a/exercises/exercise-14/database.ts +++ b/exercises/exercise-14/database.ts @@ -1,13 +1,228 @@ +import {readFile} from 'fs'; + + +type FieldQuery = + | {$eq: FT} + | {$lt: FT} + | {$gt: FT} + | {$in: FT[]} + + +type Options = { + sort?: {[key in keyof T]?: 1 | -1} + projection?: {[key in keyof T]?: 1} +} + +type Query = {[K in keyof T]?: FieldQuery} & { + $text?: string; + $and?: Query[]; + $or?: Query[]; +} + +interface Documents { + [key: string]: boolean; +} + + +function intersectSearchResults(documents: Documents[]) { + const results: Documents = {} + if (documents.length === 0) { + return results + } + + for(let key of Object.keys(documents[0])) { + let keep = true; + for (let i = 0; i < documents.length; i++) { + if (!documents[i][key]) { + keep = false; + break; + } + } + + if (keep) { + results[key] = true; + } + } + + return results; + +} + + +function mergeSearchResults(documents: Documents[]) { + const results: Documents = {}; + for (const document of documents) { + for (const key of Object.keys(document)) { + results[key] = true + } + } + + return results +} + + +function isEmptyObject(obj: object): boolean { + if (Object.keys(obj).length === 0) { + return true; + } + return false; +} + + +function getSortItems(o: T): [keyof T, T[keyof T]][] { + return Object.entries(o) as any; +} + +function pick(obj: T, pickItem: {[k in K]?: 1}): {[k in K]: T[k]} { + let out: Partial = {} + + for (const key in pickItem) { + out[key] = obj[key] + } + return out as any; +} + export class Database { protected filename: string; - protected fullTextSearchFieldNames: unknown[]; + protected fullTextSearchFieldNames: (keyof T)[]; + protected getDocumentsPromise: Promise | null = null; + protected getFullTextSearchIndexPromise: Promise | null = null; - constructor(filename: string, fullTextSearchFieldNames) { + constructor(filename: string, fullTextSearchFieldNames: (keyof T)[]) { this.filename = filename; this.fullTextSearchFieldNames = fullTextSearchFieldNames; } - async find(query, options?): Promise { - return []; + async find(query: Query, options: Options | null = null): Promise { + let results: T[] = []; + const documents = await this.getDocuments(); + + if (isEmptyObject(query)) { + results = documents; + } else { + results = Object.keys(await this.findMatchingDocuments(query)) + .map(Number) + .map((index) => documents[index]) + } + + + if (options) { + const {sort, projection} = options; + if (sort) { + for (const [k, v] of getSortItems(sort || {})) { + results = results.sort((s1, s2) => v > 0 ? (Number(s1[k]) - Number(s2[k])) : (Number(s2[k]) - Number(s1[k]))); + } + } + + if (projection) { + results = results.map((result) => pick(result, projection)) + } + } + + return results; + } + + protected getDocuments() { + return this.getDocumentsPromise || (new Promise((resolve, reject) => { + readFile(this.filename, 'utf8', (error, data) => { + if (error) { + reject(error); + return; + } + resolve( + data + .trim() + .split('\n') + .filter((line) => line[0] === 'E') + .map((line) => JSON.parse(line.substr(1))) + ); + }); + })) + } + + protected getFullTextSearchIndex() { + return this.getFullTextSearchIndexPromise || this.getDocuments().then((documents) => { + const fullTextSearchIndex = new FullTextSearchIndex(); + documents.forEach((document, index) => { + fullTextSearchIndex.addDocument( + index, + this.fullTextSearchFieldNames.map((key) => String(document[key])) + ); + }) + return fullTextSearchIndex; + }) + } + + protected async getMatchingDocumentsIds(comparator: (document: T) => boolean) { + const result: Documents = {}; + const documents = await this.getDocuments(); + for(let i = 0; i < documents.length; i++) { + if (comparator(documents[i])) { + result[i] = true; + } + } + return result; + } + + protected async findMatchingDocuments(query: Query) : Promise{ + const result: Documents[] = []; + + for (const key of Object.keys(query) as (keyof Query)[]) { + if (key === '$text') { + result.push((await this.getFullTextSearchIndex()).search(query.$text!)) + } else if (key === '$and') { + result.push( + intersectSearchResults(await Promise.all(query.$and!.map(this.findMatchingDocuments, this))) + ); + } else if (key === '$or') { + result.push(mergeSearchResults(await Promise.all(query.$or!.map(this.findMatchingDocuments, this)))); + } else { + const fieldQuery = query[key] as FieldQuery + if ('$eq' in fieldQuery) { + result.push(await this.getMatchingDocumentsIds((document) => document[key] === fieldQuery.$eq)) + } else if ('$gt' in fieldQuery) { + result.push(await this.getMatchingDocumentsIds((document) => Number(document[key]) > Number(fieldQuery.$gt))) + } else if ('$lt' in fieldQuery) { + result.push(await this.getMatchingDocumentsIds((document) => Number(document[key]) < Number(fieldQuery.$lt))) + } else if ('$in' in fieldQuery) { + const index: {[key: string] : boolean} = {} + for(const value of fieldQuery.$in) { + index[String(value)] = true; + } + result.push(await this.getMatchingDocumentsIds((document) => index.hasOwnProperty(String(document[key])))) + } else { + throw new Error('Incorrect query') + } + } + } + + return intersectSearchResults(result) + } +} + +class FullTextSearchIndex { + protected wordsToDocuments: {[words: string]: Documents} = {} + + protected breakTextIntowords (text: string) { + return text.toLowerCase().replace(/\W+/g, ' ').trim().split(' '); + } + + + addDocument(documentIndex: number, texts: string[]) { + for(const text of texts) { + const words = this.breakTextIntowords(text); + for(let word of words) { + this.wordsToDocuments[word] = this.wordsToDocuments[word] || {}; + this.wordsToDocuments[word][documentIndex] = true; + } + } + } + + search(query: string): Documents { + return intersectSearchResults( + this.breakTextIntowords(query) + .map((word) => this.wordsToDocuments[word]) + .filter(Boolean) + ) } } diff --git a/exercises/exercise-15/database.ts b/exercises/exercise-15/database.ts index 7facb269..4c9904be 100644 --- a/exercises/exercise-15/database.ts +++ b/exercises/exercise-15/database.ts @@ -1,13 +1,285 @@ +import * as fs from 'fs'; + + +type FieldQuery = + | {$eq: FT} + | {$lt: FT} + | {$gt: FT} + | {$in: FT[]} + + +type Options = { + sort?: {[key in keyof T]?: 1 | -1} + projection?: {[key in keyof T]?: 1} +} + +type Query = {[K in keyof T]?: FieldQuery} & { + $text?: string; + $and?: Query[]; + $or?: Query[]; +} + +interface Documents { + [key: string]: boolean; +} + +type IndexedRecord = T & { + $index?: {[word: string]: true}; + $deleted?: boolean; +}; + + + +function intersectSearchResults(documents: Documents[]) { + const results: Documents = {} + if (documents.length === 0) { + return results + } + + for(let key of Object.keys(documents[0])) { + let keep = true; + for (let i = 0; i < documents.length; i++) { + if (!documents[i][key]) { + keep = false; + break; + } + } + + if (keep) { + results[key] = true; + } + } + + return results; + +} + +function mergeSearchResults(documents: Documents[]) { + const results: Documents = {}; + for (const document of documents) { + for (const key of Object.keys(document)) { + results[key] = true + } + } + + return results +} + +function isEmptyObject(obj: object): boolean { + if (Object.keys(obj).length === 0) { + return true; + } + return false; +} + +function getSortItems(o: T): [keyof T, T[keyof T]][] { + return Object.entries(o) as any; +} + +function pick(obj: T, pickItem: {[k in K]?: 1}): {[k in K]: T[k]} { + let out: Partial = {} + + for (const key in pickItem) { + out[key] = obj[key] + } + return out as any; +} + export class Database { protected filename: string; - protected fullTextSearchFieldNames: unknown[]; + protected fullTextSearchFieldNames: (keyof T)[]; + protected getDocumentsPromise: Promise | null = null; + protected getFullTextSearchIndexPromise: Promise | null = null; + protected records: IndexedRecord[]; - constructor(filename: string, fullTextSearchFieldNames) { + constructor(filename: string, fullTextSearchFieldNames: (keyof T)[]) { this.filename = filename; this.fullTextSearchFieldNames = fullTextSearchFieldNames; + const text = fs.readFileSync(filename, 'utf8'); + const lines = text.split('\n'); + this.records = lines + .filter(line => line) + .map(line => ({...JSON.parse(line.slice(1)), $deleted: line[0] === 'D'})) + } + + async find(query: Query, options: Options | null = null): Promise { + let results: T[] = []; + const documents = await this.getDocuments(); + + if (isEmptyObject(query)) { + results = documents; + } else { + results = Object.keys(await this.findMatchingDocuments(query)) + .map(Number) + .map((index) => documents[index]) + } + + + if (options) { + const {sort, projection} = options; + if (sort) { + for (const [k, v] of getSortItems(sort || {})) { + results = results.sort((s1, s2) => v > 0 ? (Number(s1[k]) - Number(s2[k])) : (Number(s2[k]) - Number(s1[k]))); + } + } + + if (projection) { + results = results.map((result) => pick(result, projection)) + } + } + + return results; + } + + async delete(query: Query) { + let results: number[] = []; + results = Object.keys(await this.findMatchingDocuments(query, true)).map(Number) + for(const result of results) { + this.records[result].$deleted = true + } + + this.setDocuments(this.records); + } + + async insert(newRecord: T) { + this.records.push({ + ...newRecord + }) + + await this.setDocuments(this.records); + } + + protected getDocuments(findAll: boolean = false) { + return this.getDocumentsPromise || (new Promise((resolve, reject) => { + fs.readFile(this.filename, 'utf8', (error, data) => { + if (error) { + reject(error); + return; + } + + if (findAll) { + resolve( + data + .trim() + .split('\n') + .map((line) => JSON.parse(line.substr(1))) + ); + } else { + resolve( + data + .trim() + .split('\n') + .filter((line) => line[0] === 'E') + .map((line) => JSON.parse(line.substr(1))) + ); + } + }); + })) + } + + protected async setDocuments(data: IndexedRecord[]) { + return new Promise((resolve, reject) => { + const writeData = data.map(item => { + if (item.$deleted) { + return 'D' + JSON.stringify(item) + } else { + return 'E' + JSON.stringify(item) + } + }); + + fs.writeFile(this.filename, writeData.join('\n'), (error) => { + if (error) { + reject(error); + } + resolve() + }) + }) + } + + protected getFullTextSearchIndex() { + return this.getFullTextSearchIndexPromise || this.getDocuments().then((documents) => { + const fullTextSearchIndex = new FullTextSearchIndex(); + documents.forEach((document, index) => { + fullTextSearchIndex.addDocument( + index, + this.fullTextSearchFieldNames.map((key) => String(document[key])) + ); + }) + return fullTextSearchIndex; + }) + } + + protected async getMatchingDocumentsIds(comparator: (document: T) => boolean, findAll: boolean = false) { + const result: Documents = {}; + const documents = await this.getDocuments(findAll); + for(let i = 0; i < documents.length; i++) { + if (comparator(documents[i])) { + result[i] = true; + } + } + return result; + } + + protected async findMatchingDocuments(query: Query, findAll: boolean = false) : Promise{ + const result: Documents[] = []; + + for (const key of Object.keys(query) as (keyof Query)[]) { + if (key === '$text') { + result.push((await this.getFullTextSearchIndex()).search(query.$text!)) + } else if (key === '$and') { + // 链式调用 + result.push( + intersectSearchResults(await Promise.all(query.$and!.map(this.findMatchingDocuments.bind(this, query, findAll), this))) + ); + } else if (key === '$or') { + result.push(mergeSearchResults(await Promise.all(query.$or!.map(this.findMatchingDocuments.bind(this, query, findAll), this)))); + } else { + const fieldQuery = query[key] as FieldQuery + if ('$eq' in fieldQuery) { + result.push(await this.getMatchingDocumentsIds((document) => document[key] === fieldQuery.$eq, findAll)) + } else if ('$gt' in fieldQuery) { + result.push(await this.getMatchingDocumentsIds((document) => Number(document[key]) > Number(fieldQuery.$gt), findAll)) + } else if ('$lt' in fieldQuery) { + result.push(await this.getMatchingDocumentsIds((document) => Number(document[key]) < Number(fieldQuery.$lt), findAll)) + } else if ('$in' in fieldQuery) { + const index: {[key: string] : boolean} = {} + for(const value of fieldQuery.$in) { + index[String(value)] = true; + } + result.push(await this.getMatchingDocumentsIds((document) => index.hasOwnProperty(String(document[key])), findAll)) + } else { + throw new Error('Incorrect query') + } + } + } + + return intersectSearchResults(result) + } +} + +class FullTextSearchIndex { + protected wordsToDocuments: {[words: string]: Documents} = {} + + protected breakTextIntowords (text: string) { + return text.toLowerCase().replace(/\W+/g, ' ').trim().split(' '); + } + + + addDocument(documentIndex: number, texts: string[]) { + for(const text of texts) { + const words = this.breakTextIntowords(text); + for(let word of words) { + this.wordsToDocuments[word] = this.wordsToDocuments[word] || {}; + this.wordsToDocuments[word][documentIndex] = true; + } + } } - async find(query, options?): Promise { - return []; + search(query: string): Documents { + return intersectSearchResults( + this.breakTextIntowords(query) + .map((word) => this.wordsToDocuments[word]) + .filter(Boolean) + ) } }