From 52c2083b9d67df5191a5c40d1d1c9934d6341a86 Mon Sep 17 00:00:00 2001 From: AshwinM1523 Date: Sat, 2 Nov 2024 13:56:54 -0400 Subject: [PATCH 1/5] Create ParseOracleDocMetadata --- .../document_loaders/tests/oracleai.test.ts | 56 ++++++++++++++ .../src/document_loaders/web/oracleai.ts | 77 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 libs/langchain-community/src/document_loaders/tests/oracleai.test.ts create mode 100644 libs/langchain-community/src/document_loaders/web/oracleai.ts diff --git a/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts b/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts new file mode 100644 index 000000000000..4c28313b8986 --- /dev/null +++ b/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts @@ -0,0 +1,56 @@ +import { ParseOracleDocMetadata } from "../web/oracleai.js"; + +describe("ParseOracleDocMetadata", () => { + let parser: ParseOracleDocMetadata; + + beforeEach(() => { + parser = new ParseOracleDocMetadata(); + }); + + test("should parse title and meta tags correctly", () => { + const htmlString = "Sample Title"; + parser.parse(htmlString); + const metadata = parser.getMetadata(); + expect(metadata).toEqual({ + title: "Sample Title", + description: "Sample Content", + }); + }); + + test("should handle missing meta content gracefully", () => { + const htmlString = "Sample Title"; + parser.parse(htmlString); + const metadata = parser.getMetadata(); + expect(metadata).toEqual({ + title: "Sample Title", + description: "N/A", + }); + }); + + test("should handle multiple meta tags", () => { + const htmlString = "Sample Title"; + parser.parse(htmlString); + const metadata = parser.getMetadata(); + expect(metadata).toEqual({ + title: "Sample Title", + description: "Sample Content", + author: "John Doe", + }); + }); + + test("should handle no title tag", () => { + const htmlString = ""; + parser.parse(htmlString); + const metadata = parser.getMetadata(); + expect(metadata).toEqual({ + description: "Sample Content", + }); + }); + + test("should handle empty html string", () => { + const htmlString = ""; + parser.parse(htmlString); + const metadata = parser.getMetadata(); + expect(metadata).toEqual({}); + }); +}); \ No newline at end of file diff --git a/libs/langchain-community/src/document_loaders/web/oracleai.ts b/libs/langchain-community/src/document_loaders/web/oracleai.ts new file mode 100644 index 000000000000..67b80d8839cf --- /dev/null +++ b/libs/langchain-community/src/document_loaders/web/oracleai.ts @@ -0,0 +1,77 @@ +import { Document } from "@langchain/core/documents"; +import { BaseDocumentLoader } from "@langchain/core/document_loaders"; +import { Parser, DomHandler } from "htmlparser2"; + +interface Metadata { + [key: string]: string; +} + +export class ParseOracleDocMetadata { + private metadata: Metadata; + private match: boolean; + + constructor() { + this.metadata = {}; + this.match = false; + } + + private handleStartTag(tag: string, attrs: { name: string; value: string | null }[]) { + if (tag === "meta") { + let entry: string | undefined; + let content: string | null = null; + + attrs.forEach(({ name, value }) => { + if (name === "name") entry = value ?? ""; + if (name === "content") content = value; + }); + + if (entry) { + this.metadata[entry] = content ?? "N/A"; + } + } else if (tag === "title") { + this.match = true; + } + } + + private handleData(data: string) { + if (this.match) { + this.metadata["title"] = data; + this.match = false; + } + } + + public getMetadata(): Metadata { + return this.metadata; + } + + public parse(htmlString: string): void { + // We add this method to incorperate the feed method of HTMLParser in Python + interface Attribute { + name: string; + value: string | null; + } + + interface ParserOptions { + onopentag: (name: string, attrs: Record) => void; + ontext: (text: string) => void; + } + + const parser = new Parser( + { + onopentag: (name: string, attrs: Record) => + this.handleStartTag( + name, + Object.entries(attrs).map(([name, value]): Attribute => ({ + name, + value: value as string | null, + })) + ), + ontext: (text: string) => this.handleData(text), + } as ParserOptions, + { decodeEntities: true } + ); + parser.write(htmlString); + parser.end(); + } + +} \ No newline at end of file From 14c4df9fea9a416387d483f111c884e7b4cfd40a Mon Sep 17 00:00:00 2001 From: Asad Date: Sat, 9 Nov 2024 18:14:46 -0500 Subject: [PATCH 2/5] Created OracleDocReader class --- .../src/document_loaders/web/oracleai.ts | 151 +++++++++++++++++- 1 file changed, 148 insertions(+), 3 deletions(-) diff --git a/libs/langchain-community/src/document_loaders/web/oracleai.ts b/libs/langchain-community/src/document_loaders/web/oracleai.ts index 67b80d8839cf..3f061846cd88 100644 --- a/libs/langchain-community/src/document_loaders/web/oracleai.ts +++ b/libs/langchain-community/src/document_loaders/web/oracleai.ts @@ -1,12 +1,22 @@ import { Document } from "@langchain/core/documents"; -import { BaseDocumentLoader } from "@langchain/core/document_loaders"; +import { BaseDocumentLoader } from "@langchain/core/document_loaders/base"; import { Parser, DomHandler } from "htmlparser2"; +import oracledb from "oracledb"; +import crypto from "crypto"; +import fs from "fs"; + + interface Metadata { [key: string]: string; } -export class ParseOracleDocMetadata { +interface OutBinds { + mdata: oracledb.Lob | null; + text: oracledb.Lob | null; +} + +class ParseOracleDocMetadata { private metadata: Metadata; private match: boolean; @@ -74,4 +84,139 @@ export class ParseOracleDocMetadata { parser.end(); } -} \ No newline at end of file +} + + + +class OracleDocReader { + static generateObjectId(inputString: string | null = null) { + const outLength = 32; // Output length + const hashLen = 8; // Hash value length + + if (!inputString) { + inputString = Array.from( + { length: 16 }, + () => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + .charAt(Math.floor(Math.random() * 62)) + ).join(""); + } + + // Timestamp + const timestamp = Math.floor(Date.now() / 1000); + const timestampBin = Buffer.alloc(4); + timestampBin.writeUInt32BE(timestamp); + + // Hash value + const hashValBin = crypto.createHash("sha256").update(inputString).digest(); + const truncatedHashVal = hashValBin.slice(0, hashLen); + + // Counter + const counterBin = Buffer.alloc(4); + counterBin.writeUInt32BE(Math.floor(Math.random() * Math.pow(2, 32))); + + // Binary object ID + const objectId = Buffer.concat([timestampBin, truncatedHashVal, counterBin]); + let objectIdHex = objectId.toString("hex").padStart(outLength, "0"); + + return objectIdHex.slice(0, outLength); + } + +// Helper function to read CLOB data + static async readClob(lob: oracledb.Lob): Promise { + return new Promise((resolve, reject) => { + let clobData = ""; + lob.setEncoding("utf8"); + lob.on("data", (chunk) => { + clobData += chunk; + }); + lob.on("end", () => { + resolve(clobData); + }); + lob.on("error", (err) => { + reject(err); + }); + }); + } + + + static async readFile( + conn: oracledb.Connection, + filePath: string, + params: Record + ): Promise { + let metadata: Metadata = {}; + + try { + // Read the file as binary data + const data = await new Promise((resolve, reject) => { + const fs = require("fs"); + fs.readFile(filePath, (err: NodeJS.ErrnoException | null, data: Buffer) => { + if (err) reject(err); + else resolve(data); + }); + }); + + if (!data) { + return new Document("", metadata); + } + + const bindVars = { + blob: { dir: oracledb.BIND_IN, type: oracledb.DB_TYPE_BLOB, val: data }, + pref: { dir: oracledb.BIND_IN, val: JSON.stringify(params) }, + mdata: { dir: oracledb.BIND_OUT, type: oracledb.DB_TYPE_CLOB }, + text: { dir: oracledb.BIND_OUT, type: oracledb.DB_TYPE_CLOB }, + }; + + // Execute the PL/SQL block + const result = await conn.execute( + ` + declare + input blob; + begin + input := :blob; + :mdata := dbms_vector_chain.utl_to_text(input, json(:pref)); + :text := dbms_vector_chain.utl_to_text(input); + end;`, + bindVars + ); + + const outBinds = result.outBinds as OutBinds; + const mdataLob = outBinds.mdata; + const textLob = outBinds.text; + + // Read and parse metadata + let docData = mdataLob ? await OracleDocReader.readClob(mdataLob) : ""; + let textData = textLob ? await OracleDocReader.readClob(textLob) : ""; + + if ( + docData.startsWith("") + ) { + const parser = new ParseOracleDocMetadata(); + parser.parse(docData); + metadata = parser.getMetadata(); + } + + // Execute a query to get the current session user + const userResult = await conn.execute<{ USERNAME: string }>( + `SELECT USER FROM dual` + ); + + const username = userResult.rows?.[0]?.USERNAME; + const docId = OracleDocReader.generateObjectId(`${username}$${filePath}`); + metadata["_oid"] = docId; + metadata["_file"] = filePath; + + if (!textData) { + return Document("", metadata) + } else { + return Document(textData, metadata) + } + } catch (ex) { + console.error(`An exception occurred: ${ex}`); + console.error(`Skip processing ${filePath}`); + return null; + } + } + +} From fc84e27f9abd31d30c509b940aab913bdf1a2ccc Mon Sep 17 00:00:00 2001 From: AhmadHakim2004 Date: Sat, 16 Nov 2024 16:29:28 -0500 Subject: [PATCH 3/5] Created OracleDocLoader class and UTs for loading files and dirs --- .../oracleai/Jacob_Lee_Resume_2023.pdf | Bin 0 -> 73667 bytes .../tests/example_data/oracleai/example.html | 25 ++++ .../tests/example_data/oracleai/example.txt | 4 + .../document_loaders/tests/oracleai.test.ts | 65 ++++++++++- .../src/document_loaders/web/oracleai.ts | 107 +++++++++++++----- 5 files changed, 168 insertions(+), 33 deletions(-) create mode 100644 libs/langchain-community/src/document_loaders/tests/example_data/oracleai/Jacob_Lee_Resume_2023.pdf create mode 100644 libs/langchain-community/src/document_loaders/tests/example_data/oracleai/example.html create mode 100644 libs/langchain-community/src/document_loaders/tests/example_data/oracleai/example.txt diff --git a/libs/langchain-community/src/document_loaders/tests/example_data/oracleai/Jacob_Lee_Resume_2023.pdf b/libs/langchain-community/src/document_loaders/tests/example_data/oracleai/Jacob_Lee_Resume_2023.pdf new file mode 100644 index 0000000000000000000000000000000000000000..de0724b537719d6ecf4f33a541d425423f820b20 GIT binary patch literal 73667 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

V_p7IEHRjb_Fu`b|bR5BO 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

j8N!NaODxg` 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 literal 0 HcmV?d00001 diff --git a/libs/langchain-community/src/document_loaders/tests/example_data/oracleai/example.html b/libs/langchain-community/src/document_loaders/tests/example_data/oracleai/example.html new file mode 100644 index 000000000000..fbfa6c5ce47c --- /dev/null +++ b/libs/langchain-community/src/document_loaders/tests/example_data/oracleai/example.html @@ -0,0 +1,25 @@ + + + + + + + + Sample HTML Page + + +
+

Welcome to My Sample HTML Page

+
+ +
+

Introduction

+

This is a small HTML file with a header, main content section, and a footer.

+

Feel free to modify and experiment with the code!

+
+ +
+

Footer Content - © 2024

+
+ + \ No newline at end of file diff --git a/libs/langchain-community/src/document_loaders/tests/example_data/oracleai/example.txt b/libs/langchain-community/src/document_loaders/tests/example_data/oracleai/example.txt new file mode 100644 index 000000000000..04861c126cfe --- /dev/null +++ b/libs/langchain-community/src/document_loaders/tests/example_data/oracleai/example.txt @@ -0,0 +1,4 @@ +Foo +Bar +Baz + diff --git a/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts b/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts index 4c28313b8986..08f6500d7d5f 100644 --- a/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts +++ b/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts @@ -1,4 +1,6 @@ -import { ParseOracleDocMetadata } from "../web/oracleai.js"; +import { jest } from "@jest/globals"; +import { ParseOracleDocMetadata, OracleDocLoader, OracleLoadFromType } from "../web/oracleai.js"; +import oracledb from "oracledb"; describe("ParseOracleDocMetadata", () => { let parser: ParseOracleDocMetadata; @@ -53,4 +55,63 @@ describe("ParseOracleDocMetadata", () => { const metadata = parser.getMetadata(); expect(metadata).toEqual({}); }); -}); \ No newline at end of file +}); + +describe("OracleDocLoader", () => { + let doc_count: number; + let executeMock: jest.Mock<(sql: string, bindVars?: any) => {}> + let connMock: jest.Mocked; + let loader: OracleDocLoader; + const baseDirPath = "./src/document_loaders/tests/example_data/oracleai"; + const baseMockData = "MockData" + + beforeEach(() => { + doc_count = 0; + executeMock = jest.fn(); + + executeMock.mockImplementation(async (sql: string, bindVars?: {}) => { + if (bindVars) { + doc_count++; + return { + outBinds: { + mdata: { getData: jest.fn().mockImplementation( () => bindVars.blob.val.toString() ) }, + text: { getData: jest.fn().mockImplementation( () => baseMockData + doc_count ) } } + }; + } + else { + return { + rows: [['MockUser']] + }; + } + }); + + connMock = {execute: executeMock} as unknown as jest.Mocked; + }); + + test("should load a single file properly", async () => { + loader = new OracleDocLoader(connMock, baseDirPath + "/example.html", OracleLoadFromType.FILE); + const res = await loader.load(); + console.log(res) + expect(res.length).toEqual(1); + expect(res[0].pageContent).toEqual(baseMockData + "1"); + expect(res[0].metadata.title).toBeTruthy(); + expect(res[0].metadata.title).toEqual("Sample HTML Page"); + expect(res[0].metadata.viewport).toBeTruthy(); + expect(res[0].metadata.viewport).toEqual("width=device-width, initial-scale=1.0"); + }); + + test("should load a directory properly", async () => { + loader = new OracleDocLoader(connMock, baseDirPath, OracleLoadFromType.DIR); + const res = await loader.load(); + + expect(res.length).toEqual(3); + for (let i = 0; i < res.length; i += 1) { + expect(res[i].pageContent).toEqual(baseMockData + (i+1)); + if (res[i].metadata.title) { + expect(res[i].metadata.title).toEqual("Sample HTML Page"); + expect(res[i].metadata.viewport).toBeTruthy(); + expect(res[i].metadata.viewport).toEqual("width=device-width, initial-scale=1.0"); + } + } + }); +}); diff --git a/libs/langchain-community/src/document_loaders/web/oracleai.ts b/libs/langchain-community/src/document_loaders/web/oracleai.ts index 3f061846cd88..0874c3d82842 100644 --- a/libs/langchain-community/src/document_loaders/web/oracleai.ts +++ b/libs/langchain-community/src/document_loaders/web/oracleai.ts @@ -1,9 +1,10 @@ import { Document } from "@langchain/core/documents"; import { BaseDocumentLoader } from "@langchain/core/document_loaders/base"; -import { Parser, DomHandler } from "htmlparser2"; +import { Parser } from "htmlparser2"; import oracledb from "oracledb"; import crypto from "crypto"; import fs from "fs"; +import path from 'path'; @@ -16,7 +17,7 @@ interface OutBinds { text: oracledb.Lob | null; } -class ParseOracleDocMetadata { +export class ParseOracleDocMetadata { private metadata: Metadata; private match: boolean; @@ -121,24 +122,6 @@ class OracleDocReader { return objectIdHex.slice(0, outLength); } -// Helper function to read CLOB data - static async readClob(lob: oracledb.Lob): Promise { - return new Promise((resolve, reject) => { - let clobData = ""; - lob.setEncoding("utf8"); - lob.on("data", (chunk) => { - clobData += chunk; - }); - lob.on("end", () => { - resolve(clobData); - }); - lob.on("error", (err) => { - reject(err); - }); - }); - } - - static async readFile( conn: oracledb.Connection, filePath: string, @@ -149,7 +132,6 @@ class OracleDocReader { try { // Read the file as binary data const data = await new Promise((resolve, reject) => { - const fs = require("fs"); fs.readFile(filePath, (err: NodeJS.ErrnoException | null, data: Buffer) => { if (err) reject(err); else resolve(data); @@ -157,7 +139,7 @@ class OracleDocReader { }); if (!data) { - return new Document("", metadata); + return new Document({pageContent: "", metadata}); } const bindVars = { @@ -185,8 +167,11 @@ class OracleDocReader { const textLob = outBinds.text; // Read and parse metadata - let docData = mdataLob ? await OracleDocReader.readClob(mdataLob) : ""; - let textData = textLob ? await OracleDocReader.readClob(textLob) : ""; + let docData = await mdataLob?.getData(); + let textData = await textLob?.getData(); + + docData = docData ? docData.toString() : ""; + textData = textData ? textData.toString() : ""; if ( docData.startsWith("( + const userResult = await conn.execute( `SELECT USER FROM dual` ); - const username = userResult.rows?.[0]?.USERNAME; + const username = userResult.rows?.[0]?.[0]; const docId = OracleDocReader.generateObjectId(`${username}$${filePath}`); metadata["_oid"] = docId; metadata["_file"] = filePath; - if (!textData) { - return Document("", metadata) - } else { - return Document(textData, metadata) - } + textData = textData ?? ""; + return new Document({pageContent: textData, metadata}) } catch (ex) { console.error(`An exception occurred: ${ex}`); console.error(`Skip processing ${filePath}`); @@ -220,3 +202,66 @@ class OracleDocReader { } } + +export enum OracleLoadFromType { + FILE, + DIR, + TABLE, +}; + +export class OracleDocLoader extends BaseDocumentLoader { + private conn: oracledb.Connection; + private loadFrom: string; + private loadFromType: OracleLoadFromType; + private owner?: string; + private colname?: string; + + constructor(conn: oracledb.Connection, loadFrom: string, loadFromType: OracleLoadFromType, + owner?: string, colname?: string) { + super(); + this.conn = conn; + this.loadFrom = loadFrom; + this.loadFromType = loadFromType; + this.owner = owner; + this.colname = colname; + } + + public async load(): Promise { + const documents: Document[] = [] + const m_params = {"plaintext": "false"} + + switch (this.loadFromType) { + case OracleLoadFromType.FILE: + const filepath = this.loadFrom + const doc = await OracleDocReader.readFile(this.conn, filepath, m_params) + if (doc) + documents.push(doc); + break; + + case OracleLoadFromType.DIR: + try { + const dirname = this.loadFrom; + const files = await fs.promises.readdir(dirname); + for (const file of files) { + const filepath = path.join(dirname, file); + const stats = await fs.promises.lstat(filepath); + + if (stats.isFile()) { + const doc = await OracleDocReader.readFile(this.conn, filepath, m_params) + if (doc) + documents.push(doc); + } + } + } catch (err) { + console.error('Error reading directory:', err); + } + break; + + case OracleLoadFromType.TABLE: + + default: + throw new Error("Invalid type to load from"); + } + return documents + } +} From bd1d11fc1f31990a3be56e082290b0c359bfeb19 Mon Sep 17 00:00:00 2001 From: Minjun1Kim Date: Sat, 16 Nov 2024 18:43:15 -0500 Subject: [PATCH 4/5] Implemented loading tables --- libs/langchain-community/package.json | 5 +- .../document_loaders/tests/oracleai.test.ts | 440 ++++++++++++++++-- .../document_loaders/tests/oracleaiDB.test.js | 26 ++ .../src/document_loaders/web/oracleai.ts | 188 +++++++- package.json | 8 +- yarn.lock | 105 ++++- 6 files changed, 713 insertions(+), 59 deletions(-) create mode 100644 libs/langchain-community/src/document_loaders/tests/oracleaiDB.test.js diff --git a/libs/langchain-community/package.json b/libs/langchain-community/package.json index 7b826ad1e106..9220f5723b45 100644 --- a/libs/langchain-community/package.json +++ b/libs/langchain-community/package.json @@ -116,6 +116,7 @@ "@types/d3-dsv": "^3.0.7", "@types/flat": "^5.0.2", "@types/html-to-text": "^9", + "@types/jest": "^29.5.14", "@types/jsdom": "^21.1.1", "@types/jsonwebtoken": "^9", "@types/lodash": "^4", @@ -178,7 +179,7 @@ "interface-datastore": "^8.2.11", "ioredis": "^5.3.2", "it-all": "^3.0.4", - "jest": "^29.5.0", + "jest": "^29.7.0", "jest-environment-node": "^29.6.4", "jsdom": "^22.1.0", "jsonwebtoken": "^9.0.2", @@ -208,7 +209,7 @@ "rollup": "^3.19.1", "sonix-speech-recognition": "^2.1.1", "srt-parser-2": "^1.2.3", - "ts-jest": "^29.1.0", + "ts-jest": "^29.2.5", "typeorm": "^0.3.20", "typescript": "~5.1.6", "typesense": "^1.5.3", diff --git a/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts b/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts index 08f6500d7d5f..dad25a0b90ba 100644 --- a/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts +++ b/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts @@ -1,60 +1,65 @@ import { jest } from "@jest/globals"; -import { ParseOracleDocMetadata, OracleDocLoader, OracleLoadFromType } from "../web/oracleai.js"; +import { ParseOracleDocMetadata, OracleDocLoader, OracleLoadFromType, TableRow } from "../web/oracleai.js"; import oracledb from "oracledb"; describe("ParseOracleDocMetadata", () => { - let parser: ParseOracleDocMetadata; + jest.mock("oracledb"); + let parser: ParseOracleDocMetadata; - beforeEach(() => { - parser = new ParseOracleDocMetadata(); - }); + beforeEach(() => { + parser = new ParseOracleDocMetadata(); + }); - test("should parse title and meta tags correctly", () => { - const htmlString = "Sample Title"; - parser.parse(htmlString); - const metadata = parser.getMetadata(); - expect(metadata).toEqual({ - title: "Sample Title", - description: "Sample Content", - }); + test("should parse title and meta tags correctly", () => { + const htmlString = + "Sample Title"; + parser.parse(htmlString); + const metadata = parser.getMetadata(); + expect(metadata).toEqual({ + title: "Sample Title", + description: "Sample Content", }); + }); - test("should handle missing meta content gracefully", () => { - const htmlString = "Sample Title"; - parser.parse(htmlString); - const metadata = parser.getMetadata(); - expect(metadata).toEqual({ - title: "Sample Title", - description: "N/A", - }); + test("should handle missing meta content gracefully", () => { + const htmlString = + "Sample Title"; + parser.parse(htmlString); + const metadata = parser.getMetadata(); + expect(metadata).toEqual({ + title: "Sample Title", + description: "N/A", }); + }); - test("should handle multiple meta tags", () => { - const htmlString = "Sample Title"; - parser.parse(htmlString); - const metadata = parser.getMetadata(); - expect(metadata).toEqual({ - title: "Sample Title", - description: "Sample Content", - author: "John Doe", - }); + test("should handle multiple meta tags", () => { + const htmlString = + "Sample Title"; + parser.parse(htmlString); + const metadata = parser.getMetadata(); + expect(metadata).toEqual({ + title: "Sample Title", + description: "Sample Content", + author: "John Doe", }); + }); - test("should handle no title tag", () => { - const htmlString = ""; - parser.parse(htmlString); - const metadata = parser.getMetadata(); - expect(metadata).toEqual({ - description: "Sample Content", - }); + test("should handle no title tag", () => { + const htmlString = + ""; + parser.parse(htmlString); + const metadata = parser.getMetadata(); + expect(metadata).toEqual({ + description: "Sample Content", }); + }); - test("should handle empty html string", () => { - const htmlString = ""; - parser.parse(htmlString); - const metadata = parser.getMetadata(); - expect(metadata).toEqual({}); - }); + test("should handle empty html string", () => { + const htmlString = ""; + parser.parse(htmlString); + const metadata = parser.getMetadata(); + expect(metadata).toEqual({}); + }); }); describe("OracleDocLoader", () => { @@ -115,3 +120,352 @@ describe("OracleDocLoader", () => { } }); }); + +describe('OracleDocLoader - loadFromTable', () => { + let conn: Partial; + let executeMock: any; + + beforeEach(() => { + executeMock = jest.fn(); + conn = { + execute: executeMock, + }; + }); + + test('loadFromTable with valid parameters', async () => { + // Mock the execute method for the column type query + executeMock.mockResolvedValueOnce({ + rows: [ + { COLUMN_NAME: 'COL1', DATA_TYPE: 'VARCHAR2' }, + { COLUMN_NAME: 'COL2', DATA_TYPE: 'NUMBER' }, + { COLUMN_NAME: 'COL3', DATA_TYPE: 'DATE' }, + ], + } as oracledb.Result<{ COLUMN_NAME: string; DATA_TYPE: string }>); + + // Mock the execute method for getting username + executeMock.mockResolvedValueOnce({ + rows: [{ USER: 'TESTUSER' }], + } as oracledb.Result<{ USER: string }>); + + // Mock the execute method for the main query + executeMock.mockResolvedValueOnce({ + rows: [ + { + MDATA: { getData: jest.fn().mockImplementation( () => 'Title1' ) }, + TEXT: 'Text content 1', + ROWID: 'AAABBBCCC', + COL1: 'Value1', + COL2: 123, + COL3: new Date('2021-01-01'), + }, + { + MDATA: { getData: jest.fn().mockImplementation( () => 'Title2' ) }, + TEXT: 'Text content 2', + ROWID: 'AAABBBCCD', + COL1: 'Value2', + COL2: 456, + COL3: new Date('2021-02-01'), + }, + ], + }); + + const loader = new OracleDocLoader( + conn as oracledb.Connection, + 'MYTABLE', + OracleLoadFromType.TABLE, + 'MYSCHEMA', + 'MYCOLUMN', + ['COL1', 'COL2', 'COL3'] + ); + + const documents = await loader.load(); + + expect(documents).toHaveLength(2); + + expect(documents[0].pageContent).toBe('Text content 1'); + expect(documents[0].metadata).toEqual({ + title: 'Title1', + author: 'Author1', + _oid: expect.any(String), + _rowid: 'AAABBBCCC', + COL1: 'Value1', + COL2: 123, + COL3: new Date('2021-01-01'), + }); + + expect(documents[1].pageContent).toBe('Text content 2'); + expect(documents[1].metadata).toEqual({ + title: 'Title2', + author: 'Author2', + _oid: expect.any(String), + _rowid: 'AAABBBCCD', + COL1: 'Value2', + COL2: 456, + COL3: new Date('2021-02-01'), + }); + }); + + test('loadFromTable with missing owner', async () => { + const loader = new OracleDocLoader( + conn as oracledb.Connection, + 'MYTABLE', + OracleLoadFromType.TABLE, + undefined, // owner is missing + 'MYCOLUMN', + ['COL1'] + ); + + await expect(loader.load()).rejects.toThrow( + "Owner and column name must be specified for loading from a table" + ); + }); + + test('loadFromTable with missing column name', async () => { + const loader = new OracleDocLoader( + conn as oracledb.Connection, + 'MYTABLE', + OracleLoadFromType.TABLE, + 'MYSCHEMA', + undefined, // column name is missing + ['COL1'] + ); + + await expect(loader.load()).rejects.toThrow( + "Owner and column name must be specified for loading from a table" + ); + }); + + test('loadFromTable with mdata_cols exceeding 3 columns', async () => { + const loader = new OracleDocLoader( + conn as oracledb.Connection, + 'MYTABLE', + OracleLoadFromType.TABLE, + 'MYSCHEMA', + 'MYCOLUMN', + ['COL1', 'COL2', 'COL3', 'COL4'] // 4 columns, exceeding limit + ); + + await expect(loader.load()).rejects.toThrow( + "Exceeds the max number of columns you can request for metadata." + ); + }); + + test('loadFromTable with invalid column names in mdata_cols', async () => { + const loader = new OracleDocLoader( + conn as oracledb.Connection, + 'MYTABLE', + OracleLoadFromType.TABLE, + 'MYSCHEMA', + 'MYCOLUMN', + ['INVALID-COL1'] // invalid column name + ); + + await expect(loader.load()).rejects.toThrow( + "Invalid column name in mdata_cols: INVALID-COL1" + ); + }); + + test('loadFromTable with mdata_cols containing unsupported data types', async () => { + // Mock the execute method for the column type query + executeMock.mockResolvedValueOnce({ + rows: [ + { COLUMN_NAME: 'COL1', DATA_TYPE: 'CLOB' }, // Unsupported data type + ], + } as oracledb.Result<{ COLUMN_NAME: string; DATA_TYPE: string }>); + + const loader = new OracleDocLoader( + conn as oracledb.Connection, + 'MYTABLE', + OracleLoadFromType.TABLE, + 'MYSCHEMA', + 'MYCOLUMN', + ['COL1'] + ); + + await expect(loader.load()).rejects.toThrow( + 'The datatype for the column COL1 is not supported' + ); + }); + + test('loadFromTable with empty table', async () => { + // Mock the execute method for the column type query + executeMock.mockResolvedValueOnce({ + rows: [ + { COLUMN_NAME: 'COL1', DATA_TYPE: 'VARCHAR2' }, + ], + } as oracledb.Result<{ COLUMN_NAME: string; DATA_TYPE: string }>); + + // Mock the execute method for getting username + executeMock.mockResolvedValueOnce({ + rows: [{ USER: 'TESTUSER' }], + } as oracledb.Result<{ USER: string }>); + + // Mock the execute method for the main query (empty result set) + executeMock.mockResolvedValueOnce({ + rows: [], + } as oracledb.Result); + + const loader = new OracleDocLoader( + conn as oracledb.Connection, + 'MYTABLE', + OracleLoadFromType.TABLE, + 'MYSCHEMA', + 'MYCOLUMN', + ['COL1'] + ); + + const documents = await loader.load(); + + expect(documents).toHaveLength(0); + }); + + test('loadFromTable with null column data', async () => { + // Mock the execute method for the column type query + executeMock.mockResolvedValueOnce({ + rows: [ + { COLUMN_NAME: 'COL1', DATA_TYPE: 'VARCHAR2' }, + ], + } as oracledb.Result<{ COLUMN_NAME: string; DATA_TYPE: string }>); + + // Mock the execute method for getting username + executeMock.mockResolvedValueOnce({ + rows: [{ USER: 'TESTUSER' }], + } as oracledb.Result<{ USER: string }>); + + // Mock the execute method for the main query with null TEXT and MDATA + executeMock.mockResolvedValueOnce({ + rows: [ + { + MDATA: null, + TEXT: null, + ROWID: 'AAABBBCCC', + COL1: 'Value1', + }, + ], + } as oracledb.Result); + + const loader = new OracleDocLoader( + conn as oracledb.Connection, + 'MYTABLE', + OracleLoadFromType.TABLE, + 'MYSCHEMA', + 'MYCOLUMN', + ['COL1'] + ); + + const documents = await loader.load(); + + expect(documents).toHaveLength(1); + + expect(documents[0].pageContent).toBe(''); + expect(documents[0].metadata).toEqual({ + _oid: expect.any(String), + _rowid: 'AAABBBCCC', + COL1: 'Value1', + }); + }); + }); + + describe('OracleDocLoader - Integration Tests', () => { + let connection: oracledb.Connection; + const expectedDate1 = new Date('2021-01-01') + const expectedDate2 = new Date('2021-02-01') + + beforeAll(async () => { + try { + // Create a connection pool or a single connection + connection = await oracledb.getConnection({ + user: 'myuser', + password: 'mypassword', + connectString: 'localhost:1521/FREEPDB1', + }); + + // Drop the table if it exists + try { + await connection.execute(`DROP TABLE MYTABLE PURGE`); + } catch (err: any) { + // If the table doesn't exist, ignore the error + if (err.errorNum !== 942) { + // ORA-00942: table or view does not exist + throw err; + } + } + + // Set up the database schema and data + await connection.execute(` + CREATE TABLE MYTABLE ( + ID NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + MYCOLUMN CLOB, + COL1 VARCHAR2(100), + COL2 NUMBER, + COL3 DATE + ) + `); + + await connection.execute(` + INSERT INTO MYTABLE (MYCOLUMN, COL1, COL2, COL3) VALUES ( + 'Title1', + 'Value1', + 123, + :date1 + ) + `, {date1: expectedDate1}); + + await connection.execute(` + INSERT INTO MYTABLE (MYCOLUMN, COL1, COL2, COL3) VALUES ( + 'Title2', + 'Value2', + 456, + :date2 + ) + `, {date2: expectedDate2}); + + await connection.commit(); + } catch (err) { + console.error('Error during setup:', err); + throw err; // Rethrow the error to fail the tests if setup fails + } + }); + + afterAll(async () => { + try { + // Clean up the database + await connection.execute(`DROP TABLE MYTABLE PURGE`); + await connection.close(); + } catch (err) { + console.error('Error during teardown:', err); + // You might choose to ignore errors during teardown + } + }); + + test('loadFromTable with actual database connection', async () => { + const loader = new OracleDocLoader( + connection, + 'MYTABLE', + OracleLoadFromType.TABLE, + 'MYUSER', // Schema owner, replace with your actual username + 'MYCOLUMN', + ['COL1', 'COL2', 'COL3'] + ); + + const documents = await loader.load(); + + expect(documents).toHaveLength(2); + + expect(documents[0].metadata).toMatchObject({ + title: 'Title1', + author: 'Author1', + COL1: 'Value1', + COL2: 123, + COL3: expectedDate1, + }); + + expect(documents[1].metadata).toMatchObject({ + title: 'Title2', + author: 'Author2', + COL1: 'Value2', + COL2: 456, + COL3: expectedDate2, + }); + }); + }); \ No newline at end of file diff --git a/libs/langchain-community/src/document_loaders/tests/oracleaiDB.test.js b/libs/langchain-community/src/document_loaders/tests/oracleaiDB.test.js new file mode 100644 index 000000000000..85d83e3c01c4 --- /dev/null +++ b/libs/langchain-community/src/document_loaders/tests/oracleaiDB.test.js @@ -0,0 +1,26 @@ +import oracledb from 'oracledb'; + +async function testConnection() { + try { + const connection = await oracledb.getConnection({ + user: 'myuser', // Replace with your actual username + password: 'mypassword', // Replace with your actual password + connectString: 'localhost:1521/FREEPDB1', + }); + console.log('Connection successful!'); + + // Execute a query against your table + const result = await connection.execute(` + SELECT ID, MYCOLUMN, COL1, COL2, COL3 + FROM MYTABLE + `); + + console.log('Query result:', result.rows); + + await connection.close(); + } catch (err) { + console.error('Connection failed:', err); + } +} + +testConnection(); diff --git a/libs/langchain-community/src/document_loaders/web/oracleai.ts b/libs/langchain-community/src/document_loaders/web/oracleai.ts index 0874c3d82842..2044e6b2ac0d 100644 --- a/libs/langchain-community/src/document_loaders/web/oracleai.ts +++ b/libs/langchain-community/src/document_loaders/web/oracleai.ts @@ -17,6 +17,13 @@ interface OutBinds { text: oracledb.Lob | null; } +export interface TableRow { + MDATA?: string | null; + TEXT?: string | null; + ROWID?: string; + [key: string]: any; +} + export class ParseOracleDocMetadata { private metadata: Metadata; private match: boolean; @@ -215,15 +222,17 @@ export class OracleDocLoader extends BaseDocumentLoader { private loadFromType: OracleLoadFromType; private owner?: string; private colname?: string; + private mdata_cols?: string[]; constructor(conn: oracledb.Connection, loadFrom: string, loadFromType: OracleLoadFromType, - owner?: string, colname?: string) { + owner?: string, colname?: string, mdata_cols?: string[]) { super(); this.conn = conn; this.loadFrom = loadFrom; this.loadFromType = loadFromType; this.owner = owner; this.colname = colname; + this.mdata_cols = mdata_cols; } public async load(): Promise { @@ -258,10 +267,185 @@ export class OracleDocLoader extends BaseDocumentLoader { break; case OracleLoadFromType.TABLE: - + return await this.loadFromTable(m_params); default: throw new Error("Invalid type to load from"); } return documents } + + private isValidIdentifier(identifier: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier); + } + + private async getUsername(): Promise { + const result = await this.conn.execute<{ USER: string }>('SELECT USER FROM dual'); + return (result.rows?.[0]?.USER) || "unknown_user"; + } + + + private async loadFromTable(m_params: any): Promise { + const results: Document[] = []; + try { + if (!this.owner || !this.colname) { + throw new Error("Owner and column name must be specified for loading from a table"); + } + + // Validate identifiers to prevent SQL injection + if (!this.isValidIdentifier(this.owner)) { + throw new Error("Invalid owner name"); + } + + if (!this.isValidIdentifier(this.loadFrom)) { + throw new Error("Invalid table name"); + } + + if (!this.isValidIdentifier(this.colname)) { + throw new Error("Invalid column name"); + } + + let mdataColsSql = ", t.ROWID"; + + if (this.mdata_cols) { + if (this.mdata_cols.length > 3) { + throw new Error("Exceeds the max number of columns you can request for metadata."); + } + + // **First, check if the column names are valid identifiers** + for (const col of this.mdata_cols) { + if (!this.isValidIdentifier(col)) { + throw new Error(`Invalid column name in mdata_cols: ${col}`); + } + } + + // Execute a query to get column data types + const colSql = ` + SELECT COLUMN_NAME, DATA_TYPE + FROM ALL_TAB_COLUMNS + WHERE OWNER = :ownername AND TABLE_NAME = :tablename + `; + + const colBinds = { + ownername: this.owner.toUpperCase(), + tablename: this.loadFrom.toUpperCase(), + }; + + const colResult = await this.conn.execute<{ COLUMN_NAME: string; DATA_TYPE: string }>( + colSql, + colBinds, + { outFormat: oracledb.OUT_FORMAT_OBJECT } + ); + + const colRows = colResult.rows; + + if (!colRows) { + throw new Error("Failed to retrieve column information"); + } + + const colTypes: Record = {}; + for (const row of colRows) { + const colName = row.COLUMN_NAME; + const dataType = row.DATA_TYPE; + colTypes[colName] = dataType; + } + + for (const col of this.mdata_cols) { + if (!this.isValidIdentifier(col)) { + throw new Error(`Invalid column name in mdata_cols: ${col}`); + } + + const dataType = colTypes[col]; + if (!dataType) { + throw new Error(`Column ${col} not found in table ${this.loadFrom}`); + } + + if ( + ![ + "NUMBER", + "BINARY_DOUBLE", + "BINARY_FLOAT", + "LONG", + "DATE", + "TIMESTAMP", + "VARCHAR2", + ].includes(dataType) + ) { + throw new Error(`The datatype for the column ${col} is not supported`); + } + } + + for (const col of this.mdata_cols) { + mdataColsSql += `, t.${col}`; + } + } + + const mainSql = ` + SELECT dbms_vector_chain.utl_to_text(t.${this.colname}, json(:params)) AS MDATA, + dbms_vector_chain.utl_to_text(t.${this.colname}) AS TEXT + ${mdataColsSql} + FROM ${this.owner}.${this.loadFrom} t + `; + + const mainBinds = { + params: JSON.stringify(m_params), + }; + + const options = { + outFormat: oracledb.OUT_FORMAT_OBJECT, + }; + + // Get the username + const userResult = await this.conn.execute<{ USER: string }>('SELECT USER FROM dual'); + const username = userResult.rows?.[0]?.USER || "unknown_user"; + + // Execute the main SQL query + const result = await this.conn.execute(mainSql, mainBinds, options); + const rows = result.rows as TableRow[]; + + if (rows) { + for (const row of rows) { + let metadata: Record = {}; + + if (row["MDATA"]) { + const data = (await (row["MDATA"] as unknown as oracledb.Lob).getData()).toString(); + if ( + data.trim().startsWith("") + ) { + const parser = new ParseOracleDocMetadata(); + parser.parse(data); + metadata = { ...metadata, ...parser.getMetadata() }; + } + } + + const docId = OracleDocReader.generateObjectId( + `${username}$${this.owner}$${this.loadFrom}$${this.colname}$${row["ROWID"]}` + ); + + metadata["_oid"] = docId; + metadata["_rowid"] = row["ROWID"]; + + if (this.mdata_cols) { + for (const colName of this.mdata_cols) { + metadata[colName] = row[colName]; + } + } + + const text = row["TEXT"] as string; + + if (text === null || text === undefined) { + results.push(new Document({ pageContent: "", metadata })); + } else { + results.push(new Document({ pageContent: text, metadata })); + } + } + } + + return results; + } catch (ex) { + console.error(`An exception occurred: ${ex}`); + throw ex; + } + } + } diff --git a/package.json b/package.json index d647515dcbcf..09f148fa452c 100644 --- a/package.json +++ b/package.json @@ -46,13 +46,16 @@ "license": "MIT", "devDependencies": { "@tsconfig/recommended": "^1.0.2", - "@types/jest": "^29.5.3", + "@types/jest": "^29.5.14", + "@types/oracledb": "^6", "@types/semver": "^7", "commander": "^11.1.0", "dotenv": "^16.0.3", + "jest": "^29.7.0", "lint-staged": "^13.1.1", "prettier": "^2.8.3", "semver": "^7.5.4", + "ts-jest": "^29.2.5", "turbo": "^1.13.3", "typescript": "~5.1.6" }, @@ -69,5 +72,8 @@ "eslint --cache --fix" ], "*.md": "prettier --config .prettierrc --write" + }, + "dependencies": { + "oracledb": "^6.7.0" } } diff --git a/yarn.lock b/yarn.lock index 5e6b6ed60a57..5d565c6d8c9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11758,6 +11758,7 @@ __metadata: "@types/d3-dsv": ^3.0.7 "@types/flat": ^5.0.2 "@types/html-to-text": ^9 + "@types/jest": ^29.5.14 "@types/jsdom": ^21.1.1 "@types/jsonwebtoken": ^9 "@types/lodash": ^4 @@ -11823,7 +11824,7 @@ __metadata: interface-datastore: ^8.2.11 ioredis: ^5.3.2 it-all: ^3.0.4 - jest: ^29.5.0 + jest: ^29.7.0 jest-environment-node: ^29.6.4 js-yaml: ^4.1.0 jsdom: ^22.1.0 @@ -11856,7 +11857,7 @@ __metadata: rollup: ^3.19.1 sonix-speech-recognition: ^2.1.1 srt-parser-2: ^1.2.3 - ts-jest: ^29.1.0 + ts-jest: ^29.2.5 typeorm: ^0.3.20 typescript: ~5.1.6 typesense: ^1.5.3 @@ -19312,13 +19313,13 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^29.5.3": - version: 29.5.3 - resolution: "@types/jest@npm:29.5.3" +"@types/jest@npm:^29.5.14": + version: 29.5.14 + resolution: "@types/jest@npm:29.5.14" dependencies: expect: ^29.0.0 pretty-format: ^29.0.0 - checksum: e36bb92e0b9e5ea7d6f8832baa42f087fc1697f6cd30ec309a07ea4c268e06ec460f1f0cfd2581daf5eff5763475190ec1ad8ac6520c49ccfe4f5c0a48bfa676 + checksum: 18dba4623f26661641d757c63da2db45e9524c9be96a29ef713c703a9a53792df9ecee9f7365a0858ddbd6440d98fe6b65ca67895ca5884b73cbc7ffc11f3838 languageName: node linkType: hard @@ -19614,6 +19615,15 @@ __metadata: languageName: node linkType: hard +"@types/oracledb@npm:^6": + version: 6.5.2 + resolution: "@types/oracledb@npm:6.5.2" + dependencies: + "@types/node": "*" + checksum: 02abec363e8ca1455310e930826095461c2b1e01ca7031aed99f5f0a029ee236a0a4df9c5e6d97e8757ef8e3e8531a1ce906cd65636acf355e72527bb96d4003 + languageName: node + linkType: hard + "@types/pad-left@npm:2.1.1": version: 2.1.1 resolution: "@types/pad-left@npm:2.1.1" @@ -22519,7 +22529,7 @@ __metadata: languageName: node linkType: hard -"bs-logger@npm:0.x": +"bs-logger@npm:0.x, bs-logger@npm:^0.2.6": version: 0.2.6 resolution: "bs-logger@npm:0.2.6" dependencies: @@ -22969,7 +22979,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2": +"chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -25973,6 +25983,17 @@ __metadata: languageName: node linkType: hard +"ejs@npm:^3.1.10": + version: 3.1.10 + resolution: "ejs@npm:3.1.10" + dependencies: + jake: ^10.8.5 + bin: + ejs: bin/cli.js + checksum: ce90637e9c7538663ae023b8a7a380b2ef7cc4096de70be85abf5a3b9641912dde65353211d05e24d56b1f242d71185c6d00e02cb8860701d571786d92c71f05 + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.4.284": version: 1.4.322 resolution: "electron-to-chromium@npm:1.4.322" @@ -31981,6 +32002,20 @@ __metadata: languageName: node linkType: hard +"jake@npm:^10.8.5": + version: 10.9.2 + resolution: "jake@npm:10.9.2" + dependencies: + async: ^3.2.3 + chalk: ^4.0.2 + filelist: ^1.0.4 + minimatch: ^3.1.2 + bin: + jake: bin/cli.js + checksum: f2dc4a086b4f58446d02cb9be913c39710d9ea570218d7681bb861f7eeaecab7b458256c946aeaa7e548c5e0686cc293e6435501e4047174a3b6a504dcbfcaae + languageName: node + linkType: hard + "javascript-stringify@npm:^2.0.1": version: 2.1.0 resolution: "javascript-stringify@npm:2.1.0" @@ -32724,7 +32759,7 @@ __metadata: languageName: node linkType: hard -"jest@npm:^29.5.0": +"jest@npm:^29.5.0, jest@npm:^29.7.0": version: 29.7.0 resolution: "jest@npm:29.7.0" dependencies: @@ -33396,13 +33431,17 @@ __metadata: resolution: "langchainjs@workspace:." dependencies: "@tsconfig/recommended": ^1.0.2 - "@types/jest": ^29.5.3 + "@types/jest": ^29.5.14 + "@types/oracledb": ^6 "@types/semver": ^7 commander: ^11.1.0 dotenv: ^16.0.3 + jest: ^29.7.0 lint-staged: ^13.1.1 + oracledb: ^6.7.0 prettier: ^2.8.3 semver: ^7.5.4 + ts-jest: ^29.2.5 turbo: ^1.13.3 typescript: ~5.1.6 languageName: unknown @@ -34245,7 +34284,7 @@ __metadata: languageName: node linkType: hard -"make-error@npm:1.x": +"make-error@npm:1.x, make-error@npm:^1.3.6": version: 1.3.6 resolution: "make-error@npm:1.3.6" checksum: b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402 @@ -36375,6 +36414,13 @@ __metadata: languageName: node linkType: hard +"oracledb@npm:^6.7.0": + version: 6.7.0 + resolution: "oracledb@npm:6.7.0" + checksum: f4424e30afc85256a09a23a0772e59ce551a6cbc9d12559d572a944e1278d9420fc537b5783617955e187904a6f6319ed58ef8ae7319253cdcebb01a15d8250a + languageName: node + linkType: hard + "os-name@npm:5.1.0": version: 5.1.0 resolution: "os-name@npm:5.1.0" @@ -42062,6 +42108,43 @@ __metadata: languageName: node linkType: hard +"ts-jest@npm:^29.2.5": + version: 29.2.5 + resolution: "ts-jest@npm:29.2.5" + dependencies: + bs-logger: ^0.2.6 + ejs: ^3.1.10 + fast-json-stable-stringify: ^2.1.0 + jest-util: ^29.0.0 + json5: ^2.2.3 + lodash.memoize: ^4.1.2 + make-error: ^1.3.6 + semver: ^7.6.3 + yargs-parser: ^21.1.1 + peerDependencies: + "@babel/core": ">=7.0.0-beta.0 <8" + "@jest/transform": ^29.0.0 + "@jest/types": ^29.0.0 + babel-jest: ^29.0.0 + jest: ^29.0.0 + typescript: ">=4.3 <6" + peerDependenciesMeta: + "@babel/core": + optional: true + "@jest/transform": + optional: true + "@jest/types": + optional: true + babel-jest: + optional: true + esbuild: + optional: true + bin: + ts-jest: cli.js + checksum: d60d1e1d80936f6002b1bb27f7e062408bc733141b9d666565503f023c340a3196d506c836a4316c5793af81a5f910ab49bb9c13f66e2dc66de4e0f03851dbca + languageName: node + linkType: hard + "ts-md5@npm:^1.3.1": version: 1.3.1 resolution: "ts-md5@npm:1.3.1" From 804a7db0c806404db3d3088150de73c16f4141a8 Mon Sep 17 00:00:00 2001 From: Minjun Kim Date: Sat, 23 Nov 2024 20:31:37 -0500 Subject: [PATCH 5/5] Added docs and reformatted the code --- .../document_loaders/web_loaders/oracleai.mdx | 73 ++ examples/src/document_loaders/oracleai.ts | 40 + libs/langchain-community/.gitignore | 4 + libs/langchain-community/langchain.config.js | 2 + libs/langchain-community/package.json | 21 +- .../tests/example_data/oracleai/example.html | 31 +- .../document_loaders/tests/oracleai.test.ts | 707 ++++++++---------- .../document_loaders/tests/oracleaiDB.test.js | 26 - .../src/document_loaders/web/oracleai.ts | 486 ++++++------ package.json | 8 +- yarn.lock | 104 +-- 11 files changed, 766 insertions(+), 736 deletions(-) create mode 100644 docs/core_docs/docs/integrations/document_loaders/web_loaders/oracleai.mdx create mode 100644 examples/src/document_loaders/oracleai.ts delete mode 100644 libs/langchain-community/src/document_loaders/tests/oracleaiDB.test.js diff --git a/docs/core_docs/docs/integrations/document_loaders/web_loaders/oracleai.mdx b/docs/core_docs/docs/integrations/document_loaders/web_loaders/oracleai.mdx new file mode 100644 index 000000000000..c7ef4769bd75 --- /dev/null +++ b/docs/core_docs/docs/integrations/document_loaders/web_loaders/oracleai.mdx @@ -0,0 +1,73 @@ +--- +hide_table_of_contents: true +--- + +# Oracle AI + +This example goes over how to load documents using Oracle AI Vector Search. + +## Setup + +You'll need to install the [oracledb](https://www.npmjs.com/package/oracledb) package: + +```bash npm2yarn +npm install @langchain/community @langchain/core oracledb +``` + +## Usage + +### Connect to Oracle Database +You'll need to provide the username, password, hostname and service_name: + +```typescript +import oracledb from 'oracledb'; + +let connection: oracledb.Connection; + +// Replace the placeholders with your information +const username = ""; +const password = ""; +const dsn = "/"; + +try { + connection = await oracledb.getConnection({ + user: username, + password:password, + connectString: dsn + }); + console.log("Connection Successful"); +} catch (err) { + console.error('Connection failed:', err); + throw err; +} +``` + +### Load Documents +As for loading documents, you have 3 options: +- Loading a local file. +- Loading from a local directory. +- Loading from the Oracle Database. + +When loading from the Oracle Database, you must provide the table's name, owner's name, and the name of the column to load. Optionally, you can provide extra column names to be included in the returned documents' metadata: + +```typescript +import { OracleDocLoader, OracleLoadFromType } from "@langchain/community/document_loaders/web/oracleai"; + +/* +// Loading a local file (replace with the path of the file you want to load.) +const loader = new OracleDocLoader(connection, , OracleLoadFromType.FILE); + + +// Loading from a local directory (replace with the path of the directory you want to load from.) +const loader = new OracleDocLoader(connection, , OracleLoadFromType.DIR); +*/ + +// Loading from Oracle Database table (replace the placeholders with your information, optionally add a [metadata_cols] parameter to include columns as metadata.) +const loader = new OracleDocLoader(connection, , OracleLoadFromType.TABLE, , ); + +// Load the docs +const docs = loader.load(); +console.log("Number of docs loaded:", docs.length); +console.log("Document-0:", docs[0].page_content); // content +``` + diff --git a/examples/src/document_loaders/oracleai.ts b/examples/src/document_loaders/oracleai.ts new file mode 100644 index 000000000000..c80fc818cdcf --- /dev/null +++ b/examples/src/document_loaders/oracleai.ts @@ -0,0 +1,40 @@ +import oracledb from 'oracledb'; +import { OracleDocLoader, OracleLoadFromType } from "@langchain/community/document_loaders/web/oracleai"; + +let connection: oracledb.Connection; + +// Replace the placeholders with your information +const username = ""; +const pwd = ""; +const dsn = "/"; + +try { + connection = await oracledb.getConnection({ + user: username, + password: pwd, + connectString: dsn + }); + console.log("Connection Successful"); +} catch (err) { + console.error('Connection failed:', err); + throw err; +} + +// Loading a local file (replace with the path of the file you want to load.) +const loader = new OracleDocLoader(connection, "src/document_loaders/example_data/bitcoin.pdf", OracleLoadFromType.FILE); + +/* +// Loading from a local directory (replace with the path of the directory you want to load from.) +const loader = new OracleDocLoader(connection, , OracleLoadFromType.DIR); + + +// Loading from Oracle Database table (replace the placeholders with your information, optionally add a [metadata_cols] parameter to include columns as metadata.) +const loader = new OracleDocLoader(connection, , OracleLoadFromType.TABLE, , ); +*/ + +// Load the docs +const docs = loader.load(); +console.log("Number of docs loaded:", docs.length); +console.log("Document-0:", docs[0].page_content); // content + + diff --git a/libs/langchain-community/.gitignore b/libs/langchain-community/.gitignore index e6ae5fa54a4f..79f805faafbd 100644 --- a/libs/langchain-community/.gitignore +++ b/libs/langchain-community/.gitignore @@ -922,6 +922,10 @@ document_loaders/web/notionapi.cjs document_loaders/web/notionapi.js document_loaders/web/notionapi.d.ts document_loaders/web/notionapi.d.cts +document_loaders/web/oracleai.cjs +document_loaders/web/oracleai.js +document_loaders/web/oracleai.d.ts +document_loaders/web/oracleai.d.cts document_loaders/web/pdf.cjs document_loaders/web/pdf.js document_loaders/web/pdf.d.ts diff --git a/libs/langchain-community/langchain.config.js b/libs/langchain-community/langchain.config.js index 4a402c6941e8..c2add9919f5e 100644 --- a/libs/langchain-community/langchain.config.js +++ b/libs/langchain-community/langchain.config.js @@ -286,6 +286,7 @@ export const config = { "document_loaders/web/github": "document_loaders/web/github", "document_loaders/web/taskade": "document_loaders/web/taskade", "document_loaders/web/notionapi": "document_loaders/web/notionapi", + "document_loaders/web/oracleai": "document_loaders/web/oracleai", "document_loaders/web/pdf": "document_loaders/web/pdf", "document_loaders/web/recursive_url": "document_loaders/web/recursive_url", "document_loaders/web/s3": "document_loaders/web/s3", @@ -505,6 +506,7 @@ export const config = { "document_loaders/web/pdf", "document_loaders/web/taskade", "document_loaders/web/notionapi", + "document_loaders/web/oracleai", "document_loaders/web/recursive_url", "document_loaders/web/s3", "document_loaders/web/sitemap", diff --git a/libs/langchain-community/package.json b/libs/langchain-community/package.json index 9220f5723b45..c34b967ab1f7 100644 --- a/libs/langchain-community/package.json +++ b/libs/langchain-community/package.json @@ -39,9 +39,11 @@ "binary-extensions": "^2.2.0", "expr-eval": "^2.0.2", "flat": "^5.0.2", + "htmlparser2": "^9.1.0", "js-yaml": "^4.1.0", "langchain": ">=0.2.3 <0.3.0 || >=0.3.4 <0.4.0", "langsmith": "^0.2.0", + "oracledb": "^6.7.0", "uuid": "^10.0.0", "zod": "^3.22.3", "zod-to-json-schema": "^3.22.5" @@ -116,11 +118,11 @@ "@types/d3-dsv": "^3.0.7", "@types/flat": "^5.0.2", "@types/html-to-text": "^9", - "@types/jest": "^29.5.14", "@types/jsdom": "^21.1.1", "@types/jsonwebtoken": "^9", "@types/lodash": "^4", "@types/mozilla-readability": "^0.2.1", + "@types/oracledb": "^6", "@types/pdf-parse": "^1.1.1", "@types/pg": "^8.11.0", "@types/pg-copy-streams": "^1.2.2", @@ -179,7 +181,7 @@ "interface-datastore": "^8.2.11", "ioredis": "^5.3.2", "it-all": "^3.0.4", - "jest": "^29.7.0", + "jest": "^29.5.0", "jest-environment-node": "^29.6.4", "jsdom": "^22.1.0", "jsonwebtoken": "^9.0.2", @@ -209,7 +211,7 @@ "rollup": "^3.19.1", "sonix-speech-recognition": "^2.1.1", "srt-parser-2": "^1.2.3", - "ts-jest": "^29.2.5", + "ts-jest": "^29.1.0", "typeorm": "^0.3.20", "typescript": "~5.1.6", "typesense": "^1.5.3", @@ -2792,6 +2794,15 @@ "import": "./document_loaders/web/notionapi.js", "require": "./document_loaders/web/notionapi.cjs" }, + "./document_loaders/web/oracleai": { + "types": { + "import": "./document_loaders/web/oracleai.d.ts", + "require": "./document_loaders/web/oracleai.d.cts", + "default": "./document_loaders/web/oracleai.d.ts" + }, + "import": "./document_loaders/web/oracleai.js", + "require": "./document_loaders/web/oracleai.cjs" + }, "./document_loaders/web/pdf": { "types": { "import": "./document_loaders/web/pdf.d.ts", @@ -4026,6 +4037,10 @@ "document_loaders/web/notionapi.js", "document_loaders/web/notionapi.d.ts", "document_loaders/web/notionapi.d.cts", + "document_loaders/web/oracleai.cjs", + "document_loaders/web/oracleai.js", + "document_loaders/web/oracleai.d.ts", + "document_loaders/web/oracleai.d.cts", "document_loaders/web/pdf.cjs", "document_loaders/web/pdf.js", "document_loaders/web/pdf.d.ts", diff --git a/libs/langchain-community/src/document_loaders/tests/example_data/oracleai/example.html b/libs/langchain-community/src/document_loaders/tests/example_data/oracleai/example.html index fbfa6c5ce47c..7672eb6e9e13 100644 --- a/libs/langchain-community/src/document_loaders/tests/example_data/oracleai/example.html +++ b/libs/langchain-community/src/document_loaders/tests/example_data/oracleai/example.html @@ -1,25 +1,28 @@ - - - - - + + + + + Sample HTML Page - - + +
-

Welcome to My Sample HTML Page

+

Welcome to My Sample HTML Page

-

Introduction

-

This is a small HTML file with a header, main content section, and a footer.

-

Feel free to modify and experiment with the code!

+

Introduction

+

+ This is a small HTML file with a header, main content section, and a + footer. +

+

Feel free to modify and experiment with the code!

-

Footer Content - © 2024

+

Footer Content - © 2024

- - \ No newline at end of file + + diff --git a/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts b/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts index dad25a0b90ba..9a0125d6f27e 100644 --- a/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts +++ b/libs/langchain-community/src/document_loaders/tests/oracleai.test.ts @@ -1,9 +1,13 @@ import { jest } from "@jest/globals"; -import { ParseOracleDocMetadata, OracleDocLoader, OracleLoadFromType, TableRow } from "../web/oracleai.js"; -import oracledb from "oracledb"; +import { Connection, Result } from "oracledb"; +import { + ParseOracleDocMetadata, + OracleDocLoader, + OracleLoadFromType, + TableRow, +} from "../web/oracleai.js"; describe("ParseOracleDocMetadata", () => { - jest.mock("oracledb"); let parser: ParseOracleDocMetadata; beforeEach(() => { @@ -63,409 +67,358 @@ describe("ParseOracleDocMetadata", () => { }); describe("OracleDocLoader", () => { - let doc_count: number; - let executeMock: jest.Mock<(sql: string, bindVars?: any) => {}> - let connMock: jest.Mocked; - let loader: OracleDocLoader; - const baseDirPath = "./src/document_loaders/tests/example_data/oracleai"; - const baseMockData = "MockData" - - beforeEach(() => { - doc_count = 0; - executeMock = jest.fn(); - - executeMock.mockImplementation(async (sql: string, bindVars?: {}) => { - if (bindVars) { - doc_count++; - return { - outBinds: { - mdata: { getData: jest.fn().mockImplementation( () => bindVars.blob.val.toString() ) }, - text: { getData: jest.fn().mockImplementation( () => baseMockData + doc_count ) } } - }; - } - else { - return { - rows: [['MockUser']] - }; - } - }); - - connMock = {execute: executeMock} as unknown as jest.Mocked; - }); + let executeMock: jest.Mock<(sql: string, bindVars?: any) => object>; + let connMock: jest.Mocked; + let loader: OracleDocLoader; + const baseDirPath = "./src/document_loaders/tests/example_data/oracleai"; + const baseMockData = "MockData"; - test("should load a single file properly", async () => { - loader = new OracleDocLoader(connMock, baseDirPath + "/example.html", OracleLoadFromType.FILE); - const res = await loader.load(); - console.log(res) - expect(res.length).toEqual(1); - expect(res[0].pageContent).toEqual(baseMockData + "1"); - expect(res[0].metadata.title).toBeTruthy(); - expect(res[0].metadata.title).toEqual("Sample HTML Page"); - expect(res[0].metadata.viewport).toBeTruthy(); - expect(res[0].metadata.viewport).toEqual("width=device-width, initial-scale=1.0"); - }); + beforeEach(() => { + executeMock = jest.fn(); + connMock = { execute: executeMock } as unknown as jest.Mocked; + }); - test("should load a directory properly", async () => { - loader = new OracleDocLoader(connMock, baseDirPath, OracleLoadFromType.DIR); - const res = await loader.load(); - - expect(res.length).toEqual(3); - for (let i = 0; i < res.length; i += 1) { - expect(res[i].pageContent).toEqual(baseMockData + (i+1)); - if (res[i].metadata.title) { - expect(res[i].metadata.title).toEqual("Sample HTML Page"); - expect(res[i].metadata.viewport).toBeTruthy(); - expect(res[i].metadata.viewport).toEqual("width=device-width, initial-scale=1.0"); - } - } + test("should load a single file properly", async () => { + executeMock.mockImplementation(async (sql: string, bindVars?: any) => { + if (bindVars) { + return { + outBinds: { + mdata: { + getData: jest + .fn() + .mockImplementation(() => bindVars.blob.val.toString()), + }, + text: { + getData: jest.fn().mockImplementation(() => baseMockData + 1), + }, + }, + }; + } else { + return { + rows: [["MockUser"]], + }; + } }); -}); -describe('OracleDocLoader - loadFromTable', () => { - let conn: Partial; - let executeMock: any; - - beforeEach(() => { - executeMock = jest.fn(); - conn = { - execute: executeMock, - }; + loader = new OracleDocLoader( + connMock, + baseDirPath + "/example.html", + OracleLoadFromType.FILE + ); + const res = await loader.load(); + console.log(res); + expect(res.length).toEqual(1); + expect(res[0].pageContent).toEqual(baseMockData + "1"); + expect(res[0].metadata.title).toBeTruthy(); + expect(res[0].metadata.title).toEqual("Sample HTML Page"); + expect(res[0].metadata.viewport).toBeTruthy(); + expect(res[0].metadata.viewport).toEqual( + "width=device-width, initial-scale=1.0" + ); + }); + + test("should load a directory properly", async () => { + let doc_count = 0; + executeMock.mockImplementation(async (sql: string, bindVars?: any) => { + if (bindVars) { + doc_count += 1; + return { + outBinds: { + mdata: { + getData: jest + .fn() + .mockImplementation(() => bindVars.blob.val.toString()), + }, + text: { + getData: jest + .fn() + .mockImplementation(() => baseMockData + doc_count), + }, + }, + }; + } else { + return { + rows: [["MockUser"]], + }; + } }); - - test('loadFromTable with valid parameters', async () => { - // Mock the execute method for the column type query - executeMock.mockResolvedValueOnce({ + + loader = new OracleDocLoader(connMock, baseDirPath, OracleLoadFromType.DIR); + const res = await loader.load(); + + expect(res.length).toEqual(3); + for (let i = 0; i < res.length; i += 1) { + expect(res[i].pageContent).toEqual(baseMockData + (i + 1)); + if (res[i].metadata.title) { + expect(res[i].metadata.title).toEqual("Sample HTML Page"); + expect(res[i].metadata.viewport).toBeTruthy(); + expect(res[i].metadata.viewport).toEqual( + "width=device-width, initial-scale=1.0" + ); + } + } + }); + + test("loadFromTable with valid parameters", async () => { + // Mock the execute method for the column type query + executeMock.mockImplementationOnce(() => { + return { rows: [ - { COLUMN_NAME: 'COL1', DATA_TYPE: 'VARCHAR2' }, - { COLUMN_NAME: 'COL2', DATA_TYPE: 'NUMBER' }, - { COLUMN_NAME: 'COL3', DATA_TYPE: 'DATE' }, + { COLUMN_NAME: "COL1", DATA_TYPE: "VARCHAR2" }, + { COLUMN_NAME: "COL2", DATA_TYPE: "NUMBER" }, + { COLUMN_NAME: "COL3", DATA_TYPE: "DATE" }, ], - } as oracledb.Result<{ COLUMN_NAME: string; DATA_TYPE: string }>); - - // Mock the execute method for getting username - executeMock.mockResolvedValueOnce({ - rows: [{ USER: 'TESTUSER' }], - } as oracledb.Result<{ USER: string }>); - - // Mock the execute method for the main query - executeMock.mockResolvedValueOnce({ + } as Result<{ COLUMN_NAME: string; DATA_TYPE: string }>; + }); + + // Mock the execute method for getting username + executeMock.mockImplementationOnce(() => { + return { + rows: [{ USER: "TESTUSER" }], + } as Result<{ USER: string }>; + }); + + // Mock the execute method for the main query + executeMock.mockImplementationOnce(() => { + return { rows: [ { - MDATA: { getData: jest.fn().mockImplementation( () => 'Title1' ) }, - TEXT: 'Text content 1', - ROWID: 'AAABBBCCC', - COL1: 'Value1', + MDATA: { + getData: jest + .fn() + .mockImplementation( + () => + 'Title1' + ), + }, + TEXT: "Text content 1", + ROWID: "AAABBBCCC", + COL1: "Value1", COL2: 123, - COL3: new Date('2021-01-01'), + COL3: new Date("2021-01-01"), }, { - MDATA: { getData: jest.fn().mockImplementation( () => 'Title2' ) }, - TEXT: 'Text content 2', - ROWID: 'AAABBBCCD', - COL1: 'Value2', + MDATA: { + getData: jest + .fn() + .mockImplementation( + () => + 'Title2' + ), + }, + TEXT: "Text content 2", + ROWID: "AAABBBCCD", + COL1: "Value2", COL2: 456, - COL3: new Date('2021-02-01'), + COL3: new Date("2021-02-01"), }, ], - }); - - const loader = new OracleDocLoader( - conn as oracledb.Connection, - 'MYTABLE', - OracleLoadFromType.TABLE, - 'MYSCHEMA', - 'MYCOLUMN', - ['COL1', 'COL2', 'COL3'] - ); - - const documents = await loader.load(); - - expect(documents).toHaveLength(2); - - expect(documents[0].pageContent).toBe('Text content 1'); - expect(documents[0].metadata).toEqual({ - title: 'Title1', - author: 'Author1', - _oid: expect.any(String), - _rowid: 'AAABBBCCC', - COL1: 'Value1', - COL2: 123, - COL3: new Date('2021-01-01'), - }); - - expect(documents[1].pageContent).toBe('Text content 2'); - expect(documents[1].metadata).toEqual({ - title: 'Title2', - author: 'Author2', - _oid: expect.any(String), - _rowid: 'AAABBBCCD', - COL1: 'Value2', - COL2: 456, - COL3: new Date('2021-02-01'), - }); - }); - - test('loadFromTable with missing owner', async () => { - const loader = new OracleDocLoader( - conn as oracledb.Connection, - 'MYTABLE', - OracleLoadFromType.TABLE, - undefined, // owner is missing - 'MYCOLUMN', - ['COL1'] - ); - - await expect(loader.load()).rejects.toThrow( - "Owner and column name must be specified for loading from a table" - ); - }); - - test('loadFromTable with missing column name', async () => { - const loader = new OracleDocLoader( - conn as oracledb.Connection, - 'MYTABLE', - OracleLoadFromType.TABLE, - 'MYSCHEMA', - undefined, // column name is missing - ['COL1'] - ); - - await expect(loader.load()).rejects.toThrow( - "Owner and column name must be specified for loading from a table" - ); + }; }); - - test('loadFromTable with mdata_cols exceeding 3 columns', async () => { - const loader = new OracleDocLoader( - conn as oracledb.Connection, - 'MYTABLE', - OracleLoadFromType.TABLE, - 'MYSCHEMA', - 'MYCOLUMN', - ['COL1', 'COL2', 'COL3', 'COL4'] // 4 columns, exceeding limit - ); - - await expect(loader.load()).rejects.toThrow( - "Exceeds the max number of columns you can request for metadata." - ); + + const loader = new OracleDocLoader( + connMock, + "MYTABLE", + OracleLoadFromType.TABLE, + "MYSCHEMA", + "MYCOLUMN", + ["COL1", "COL2", "COL3"] + ); + + const documents = await loader.load(); + + expect(documents).toHaveLength(2); + + expect(documents[0].pageContent).toBe("Text content 1"); + expect(documents[0].metadata).toEqual({ + title: "Title1", + author: "Author1", + _oid: expect.any(String), + _rowid: "AAABBBCCC", + COL1: "Value1", + COL2: 123, + COL3: new Date("2021-01-01"), }); - - test('loadFromTable with invalid column names in mdata_cols', async () => { - const loader = new OracleDocLoader( - conn as oracledb.Connection, - 'MYTABLE', - OracleLoadFromType.TABLE, - 'MYSCHEMA', - 'MYCOLUMN', - ['INVALID-COL1'] // invalid column name - ); - - await expect(loader.load()).rejects.toThrow( - "Invalid column name in mdata_cols: INVALID-COL1" - ); + + expect(documents[1].pageContent).toBe("Text content 2"); + expect(documents[1].metadata).toEqual({ + title: "Title2", + author: "Author2", + _oid: expect.any(String), + _rowid: "AAABBBCCD", + COL1: "Value2", + COL2: 456, + COL3: new Date("2021-02-01"), }); - - test('loadFromTable with mdata_cols containing unsupported data types', async () => { - // Mock the execute method for the column type query - executeMock.mockResolvedValueOnce({ + }); + + test("loadFromTable with missing owner", async () => { + const loader = new OracleDocLoader( + connMock, + "MYTABLE", + OracleLoadFromType.TABLE, + undefined, // owner is missing + "MYCOLUMN", + ["COL1"] + ); + + await expect(loader.load()).rejects.toThrow( + "Owner and column name must be specified for loading from a table" + ); + }); + + test("loadFromTable with missing column name", async () => { + const loader = new OracleDocLoader( + connMock, + "MYTABLE", + OracleLoadFromType.TABLE, + "MYSCHEMA", + undefined, // column name is missing + ["COL1"] + ); + + await expect(loader.load()).rejects.toThrow( + "Owner and column name must be specified for loading from a table" + ); + }); + + test("loadFromTable with mdata_cols exceeding 3 columns", async () => { + const loader = new OracleDocLoader( + connMock, + "MYTABLE", + OracleLoadFromType.TABLE, + "MYSCHEMA", + "MYCOLUMN", + ["COL1", "COL2", "COL3", "COL4"] // 4 columns, exceeding limit + ); + + await expect(loader.load()).rejects.toThrow( + "Exceeds the max number of columns you can request for metadata." + ); + }); + + test("loadFromTable with invalid column names in mdata_cols", async () => { + const loader = new OracleDocLoader( + connMock, + "MYTABLE", + OracleLoadFromType.TABLE, + "MYSCHEMA", + "MYCOLUMN", + ["INVALID-COL1"] // invalid column name + ); + + await expect(loader.load()).rejects.toThrow( + "Invalid column name in mdata_cols: INVALID-COL1" + ); + }); + + test("loadFromTable with mdata_cols containing unsupported data types", async () => { + // Mock the execute method for the column type query + executeMock.mockImplementationOnce(() => { + return { rows: [ - { COLUMN_NAME: 'COL1', DATA_TYPE: 'CLOB' }, // Unsupported data type + { COLUMN_NAME: "COL1", DATA_TYPE: "CLOB" }, // Unsupported data type ], - } as oracledb.Result<{ COLUMN_NAME: string; DATA_TYPE: string }>); - - const loader = new OracleDocLoader( - conn as oracledb.Connection, - 'MYTABLE', - OracleLoadFromType.TABLE, - 'MYSCHEMA', - 'MYCOLUMN', - ['COL1'] - ); - - await expect(loader.load()).rejects.toThrow( - 'The datatype for the column COL1 is not supported' - ); + } as Result<{ COLUMN_NAME: string; DATA_TYPE: string }>; }); - - test('loadFromTable with empty table', async () => { - // Mock the execute method for the column type query - executeMock.mockResolvedValueOnce({ - rows: [ - { COLUMN_NAME: 'COL1', DATA_TYPE: 'VARCHAR2' }, - ], - } as oracledb.Result<{ COLUMN_NAME: string; DATA_TYPE: string }>); - - // Mock the execute method for getting username - executeMock.mockResolvedValueOnce({ - rows: [{ USER: 'TESTUSER' }], - } as oracledb.Result<{ USER: string }>); - - // Mock the execute method for the main query (empty result set) - executeMock.mockResolvedValueOnce({ + + const loader = new OracleDocLoader( + connMock, + "MYTABLE", + OracleLoadFromType.TABLE, + "MYSCHEMA", + "MYCOLUMN", + ["COL1"] + ); + + await expect(loader.load()).rejects.toThrow( + "The datatype for the column COL1 is not supported" + ); + }); + + test("loadFromTable with empty table", async () => { + // Mock the execute method for the column type query + executeMock.mockImplementationOnce(() => { + return { + rows: [{ COLUMN_NAME: "COL1", DATA_TYPE: "VARCHAR2" }], + } as Result<{ COLUMN_NAME: string; DATA_TYPE: string }>; + }); + + // Mock the execute method for getting username + executeMock.mockImplementationOnce(() => { + return { + rows: [{ USER: "TESTUSER" }], + } as Result<{ USER: string }>; + }); + + // Mock the execute method for the main query (empty result set) + executeMock.mockImplementationOnce(() => { + return { rows: [], - } as oracledb.Result); - - const loader = new OracleDocLoader( - conn as oracledb.Connection, - 'MYTABLE', - OracleLoadFromType.TABLE, - 'MYSCHEMA', - 'MYCOLUMN', - ['COL1'] - ); - - const documents = await loader.load(); - - expect(documents).toHaveLength(0); + } as Result; }); - - test('loadFromTable with null column data', async () => { - // Mock the execute method for the column type query - executeMock.mockResolvedValueOnce({ - rows: [ - { COLUMN_NAME: 'COL1', DATA_TYPE: 'VARCHAR2' }, - ], - } as oracledb.Result<{ COLUMN_NAME: string; DATA_TYPE: string }>); - - // Mock the execute method for getting username - executeMock.mockResolvedValueOnce({ - rows: [{ USER: 'TESTUSER' }], - } as oracledb.Result<{ USER: string }>); - - // Mock the execute method for the main query with null TEXT and MDATA - executeMock.mockResolvedValueOnce({ + + const loader = new OracleDocLoader( + connMock, + "MYTABLE", + OracleLoadFromType.TABLE, + "MYSCHEMA", + "MYCOLUMN", + ["COL1"] + ); + + const documents = await loader.load(); + + expect(documents).toHaveLength(0); + }); + + test("loadFromTable with null column data", async () => { + // Mock the execute method for the column type query + executeMock.mockImplementationOnce(() => { + return { + rows: [{ COLUMN_NAME: "COL1", DATA_TYPE: "VARCHAR2" }], + } as Result<{ COLUMN_NAME: string; DATA_TYPE: string }>; + }); + + // Mock the execute method for getting username + executeMock.mockImplementationOnce(() => { + return { + rows: [{ USER: "TESTUSER" }], + } as Result<{ USER: string }>; + }); + + // Mock the execute method for the main query with null TEXT and MDATA + executeMock.mockImplementationOnce(() => { + return { rows: [ { MDATA: null, TEXT: null, - ROWID: 'AAABBBCCC', - COL1: 'Value1', + ROWID: "AAABBBCCC", + COL1: "Value1", }, ], - } as oracledb.Result); - - const loader = new OracleDocLoader( - conn as oracledb.Connection, - 'MYTABLE', - OracleLoadFromType.TABLE, - 'MYSCHEMA', - 'MYCOLUMN', - ['COL1'] - ); - - const documents = await loader.load(); - - expect(documents).toHaveLength(1); - - expect(documents[0].pageContent).toBe(''); - expect(documents[0].metadata).toEqual({ - _oid: expect.any(String), - _rowid: 'AAABBBCCC', - COL1: 'Value1', - }); + } as Result; }); - }); - describe('OracleDocLoader - Integration Tests', () => { - let connection: oracledb.Connection; - const expectedDate1 = new Date('2021-01-01') - const expectedDate2 = new Date('2021-02-01') - - beforeAll(async () => { - try { - // Create a connection pool or a single connection - connection = await oracledb.getConnection({ - user: 'myuser', - password: 'mypassword', - connectString: 'localhost:1521/FREEPDB1', - }); - - // Drop the table if it exists - try { - await connection.execute(`DROP TABLE MYTABLE PURGE`); - } catch (err: any) { - // If the table doesn't exist, ignore the error - if (err.errorNum !== 942) { - // ORA-00942: table or view does not exist - throw err; - } - } - - // Set up the database schema and data - await connection.execute(` - CREATE TABLE MYTABLE ( - ID NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - MYCOLUMN CLOB, - COL1 VARCHAR2(100), - COL2 NUMBER, - COL3 DATE - ) - `); - - await connection.execute(` - INSERT INTO MYTABLE (MYCOLUMN, COL1, COL2, COL3) VALUES ( - 'Title1', - 'Value1', - 123, - :date1 - ) - `, {date1: expectedDate1}); - - await connection.execute(` - INSERT INTO MYTABLE (MYCOLUMN, COL1, COL2, COL3) VALUES ( - 'Title2', - 'Value2', - 456, - :date2 - ) - `, {date2: expectedDate2}); - - await connection.commit(); - } catch (err) { - console.error('Error during setup:', err); - throw err; // Rethrow the error to fail the tests if setup fails - } - }); - - afterAll(async () => { - try { - // Clean up the database - await connection.execute(`DROP TABLE MYTABLE PURGE`); - await connection.close(); - } catch (err) { - console.error('Error during teardown:', err); - // You might choose to ignore errors during teardown - } - }); - - test('loadFromTable with actual database connection', async () => { - const loader = new OracleDocLoader( - connection, - 'MYTABLE', - OracleLoadFromType.TABLE, - 'MYUSER', // Schema owner, replace with your actual username - 'MYCOLUMN', - ['COL1', 'COL2', 'COL3'] - ); - - const documents = await loader.load(); - - expect(documents).toHaveLength(2); - - expect(documents[0].metadata).toMatchObject({ - title: 'Title1', - author: 'Author1', - COL1: 'Value1', - COL2: 123, - COL3: expectedDate1, - }); - - expect(documents[1].metadata).toMatchObject({ - title: 'Title2', - author: 'Author2', - COL1: 'Value2', - COL2: 456, - COL3: expectedDate2, - }); + const loader = new OracleDocLoader( + connMock, + "MYTABLE", + OracleLoadFromType.TABLE, + "MYSCHEMA", + "MYCOLUMN", + ["COL1"] + ); + + const documents = await loader.load(); + + expect(documents).toHaveLength(1); + + expect(documents[0].pageContent).toBe(""); + expect(documents[0].metadata).toEqual({ + _oid: expect.any(String), + _rowid: "AAABBBCCC", + COL1: "Value1", }); - }); \ No newline at end of file + }); +}); diff --git a/libs/langchain-community/src/document_loaders/tests/oracleaiDB.test.js b/libs/langchain-community/src/document_loaders/tests/oracleaiDB.test.js deleted file mode 100644 index 85d83e3c01c4..000000000000 --- a/libs/langchain-community/src/document_loaders/tests/oracleaiDB.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import oracledb from 'oracledb'; - -async function testConnection() { - try { - const connection = await oracledb.getConnection({ - user: 'myuser', // Replace with your actual username - password: 'mypassword', // Replace with your actual password - connectString: 'localhost:1521/FREEPDB1', - }); - console.log('Connection successful!'); - - // Execute a query against your table - const result = await connection.execute(` - SELECT ID, MYCOLUMN, COL1, COL2, COL3 - FROM MYTABLE - `); - - console.log('Query result:', result.rows); - - await connection.close(); - } catch (err) { - console.error('Connection failed:', err); - } -} - -testConnection(); diff --git a/libs/langchain-community/src/document_loaders/web/oracleai.ts b/libs/langchain-community/src/document_loaders/web/oracleai.ts index 2044e6b2ac0d..86866265ea1d 100644 --- a/libs/langchain-community/src/document_loaders/web/oracleai.ts +++ b/libs/langchain-community/src/document_loaders/web/oracleai.ts @@ -1,113 +1,114 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; import { Document } from "@langchain/core/documents"; import { BaseDocumentLoader } from "@langchain/core/document_loaders/base"; import { Parser } from "htmlparser2"; +import { createHash } from "crypto"; import oracledb from "oracledb"; -import crypto from "crypto"; -import fs from "fs"; -import path from 'path'; - - interface Metadata { - [key: string]: string; + [key: string]: string; } interface OutBinds { - mdata: oracledb.Lob | null; - text: oracledb.Lob | null; + mdata: oracledb.Lob | null; + text: oracledb.Lob | null; } export interface TableRow { - MDATA?: string | null; - TEXT?: string | null; - ROWID?: string; - [key: string]: any; + MDATA?: string | null; + TEXT?: string | null; + ROWID?: string; + [key: string]: any; } export class ParseOracleDocMetadata { - private metadata: Metadata; - private match: boolean; - - constructor() { - this.metadata = {}; - this.match = false; - } + private metadata: Metadata; - private handleStartTag(tag: string, attrs: { name: string; value: string | null }[]) { - if (tag === "meta") { - let entry: string | undefined; - let content: string | null = null; + private match: boolean; - attrs.forEach(({ name, value }) => { - if (name === "name") entry = value ?? ""; - if (name === "content") content = value; - }); + constructor() { + this.metadata = {}; + this.match = false; + } - if (entry) { - this.metadata[entry] = content ?? "N/A"; - } - } else if (tag === "title") { - this.match = true; - } - } + private handleStartTag( + tag: string, + attrs: { name: string; value: string | null }[] + ) { + if (tag === "meta") { + let entry: string | undefined; + let content: string | null = null; + + attrs.forEach(({ name, value }) => { + if (name === "name") entry = value ?? ""; + if (name === "content") content = value; + }); - private handleData(data: string) { - if (this.match) { - this.metadata["title"] = data; - this.match = false; - } + if (entry) { + this.metadata[entry] = content ?? "N/A"; + } + } else if (tag === "title") { + this.match = true; } + } - public getMetadata(): Metadata { - return this.metadata; + private handleData(data: string) { + if (this.match) { + this.metadata.title = data; + this.match = false; } + } - public parse(htmlString: string): void { - // We add this method to incorperate the feed method of HTMLParser in Python - interface Attribute { - name: string; - value: string | null; - } - - interface ParserOptions { - onopentag: (name: string, attrs: Record) => void; - ontext: (text: string) => void; - } + public getMetadata(): Metadata { + return this.metadata; + } - const parser = new Parser( - { - onopentag: (name: string, attrs: Record) => - this.handleStartTag( - name, - Object.entries(attrs).map(([name, value]): Attribute => ({ - name, - value: value as string | null, - })) - ), - ontext: (text: string) => this.handleData(text), - } as ParserOptions, - { decodeEntities: true } - ); - parser.write(htmlString); - parser.end(); + public parse(htmlString: string): void { + // We add this method to incorperate the feed method of HTMLParser in Python + interface Attribute { + name: string; + value: string | null; } - -} + interface ParserOptions { + onopentag: (name: string, attrs: Record) => void; + ontext: (text: string) => void; + } + const parser = new Parser( + { + onopentag: (name: string, attrs: Record) => + this.handleStartTag( + name, + Object.entries(attrs).map( + ([name, value]): Attribute => ({ + name, + value: value as string | null, + }) + ) + ), + ontext: (text: string) => this.handleData(text), + } as ParserOptions, + { decodeEntities: true } + ); + parser.write(htmlString); + parser.end(); + } +} class OracleDocReader { static generateObjectId(inputString: string | null = null) { const outLength = 32; // Output length const hashLen = 8; // Hash value length - if (!inputString) { - inputString = Array.from( - { length: 16 }, - () => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - .charAt(Math.floor(Math.random() * 62)) + const idString = + inputString ?? + Array.from({ length: 16 }, () => + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".charAt( + Math.floor(Math.random() * 62) + ) ).join(""); - } // Timestamp const timestamp = Math.floor(Date.now() / 1000); @@ -115,16 +116,20 @@ class OracleDocReader { timestampBin.writeUInt32BE(timestamp); // Hash value - const hashValBin = crypto.createHash("sha256").update(inputString).digest(); + const hashValBin = createHash("sha256").update(idString).digest(); const truncatedHashVal = hashValBin.slice(0, hashLen); // Counter const counterBin = Buffer.alloc(4); - counterBin.writeUInt32BE(Math.floor(Math.random() * Math.pow(2, 32))); + counterBin.writeUInt32BE(Math.floor(Math.random() * 2 ** 32)); // Binary object ID - const objectId = Buffer.concat([timestampBin, truncatedHashVal, counterBin]); - let objectIdHex = objectId.toString("hex").padStart(outLength, "0"); + const objectId = Buffer.concat([ + timestampBin, + truncatedHashVal, + counterBin, + ]); + const objectIdHex = objectId.toString("hex").padStart(outLength, "0"); return objectIdHex.slice(0, outLength); } @@ -139,14 +144,17 @@ class OracleDocReader { try { // Read the file as binary data const data = await new Promise((resolve, reject) => { - fs.readFile(filePath, (err: NodeJS.ErrnoException | null, data: Buffer) => { - if (err) reject(err); - else resolve(data); - }); + fs.readFile( + filePath, + (err: NodeJS.ErrnoException | null, data: Buffer) => { + if (err) reject(err); + else resolve(data); + } + ); }); if (!data) { - return new Document({pageContent: "", metadata}); + return new Document({ pageContent: "", metadata }); } const bindVars = { @@ -190,42 +198,50 @@ class OracleDocReader { } // Execute a query to get the current session user - const userResult = await conn.execute( - `SELECT USER FROM dual` - ); + const userResult = await conn.execute(`SELECT USER FROM dual`); const username = userResult.rows?.[0]?.[0]; const docId = OracleDocReader.generateObjectId(`${username}$${filePath}`); - metadata["_oid"] = docId; - metadata["_file"] = filePath; + metadata._oid = docId; + metadata._file = filePath; textData = textData ?? ""; - return new Document({pageContent: textData, metadata}) + return new Document({ pageContent: textData, metadata }); } catch (ex) { console.error(`An exception occurred: ${ex}`); console.error(`Skip processing ${filePath}`); return null; } } - } export enum OracleLoadFromType { FILE, DIR, TABLE, -}; +} export class OracleDocLoader extends BaseDocumentLoader { private conn: oracledb.Connection; + private loadFrom: string; + private loadFromType: OracleLoadFromType; + private owner?: string; + private colname?: string; + private mdata_cols?: string[]; - constructor(conn: oracledb.Connection, loadFrom: string, loadFromType: OracleLoadFromType, - owner?: string, colname?: string, mdata_cols?: string[]) { + constructor( + conn: oracledb.Connection, + loadFrom: string, + loadFromType: OracleLoadFromType, + owner?: string, + colname?: string, + mdata_cols?: string[] + ) { super(); this.conn = conn; this.loadFrom = loadFrom; @@ -236,15 +252,22 @@ export class OracleDocLoader extends BaseDocumentLoader { } public async load(): Promise { - const documents: Document[] = [] - const m_params = {"plaintext": "false"} + const documents: Document[] = []; + const m_params = { plaintext: "false" }; switch (this.loadFromType) { case OracleLoadFromType.FILE: - const filepath = this.loadFrom - const doc = await OracleDocReader.readFile(this.conn, filepath, m_params) - if (doc) - documents.push(doc); + try { + const filepath = this.loadFrom; + const doc = await OracleDocReader.readFile( + this.conn, + filepath, + m_params + ); + if (doc) documents.push(doc); + } catch (err) { + console.error("Error reading file:", err); + } break; case OracleLoadFromType.DIR: @@ -256,196 +279,197 @@ export class OracleDocLoader extends BaseDocumentLoader { const stats = await fs.promises.lstat(filepath); if (stats.isFile()) { - const doc = await OracleDocReader.readFile(this.conn, filepath, m_params) - if (doc) - documents.push(doc); + const doc = await OracleDocReader.readFile( + this.conn, + filepath, + m_params + ); + if (doc) documents.push(doc); } } } catch (err) { - console.error('Error reading directory:', err); + console.error("Error reading directory:", err); } break; case OracleLoadFromType.TABLE: - return await this.loadFromTable(m_params); - default: - throw new Error("Invalid type to load from"); - } - return documents - } - - private isValidIdentifier(identifier: string): boolean { - return /^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier); - } - - private async getUsername(): Promise { - const result = await this.conn.execute<{ USER: string }>('SELECT USER FROM dual'); - return (result.rows?.[0]?.USER) || "unknown_user"; - } - - - private async loadFromTable(m_params: any): Promise { - const results: Document[] = []; - try { - if (!this.owner || !this.colname) { - throw new Error("Owner and column name must be specified for loading from a table"); - } + try { + if (!this.owner || !this.colname) { + throw new Error( + "Owner and column name must be specified for loading from a table" + ); + } - // Validate identifiers to prevent SQL injection - if (!this.isValidIdentifier(this.owner)) { + // Validate identifiers to prevent SQL injection + if (!this.isValidIdentifier(this.owner)) { throw new Error("Invalid owner name"); - } + } - if (!this.isValidIdentifier(this.loadFrom)) { + if (!this.isValidIdentifier(this.loadFrom)) { throw new Error("Invalid table name"); - } + } - if (!this.isValidIdentifier(this.colname)) { + if (!this.isValidIdentifier(this.colname)) { throw new Error("Invalid column name"); - } + } - let mdataColsSql = ", t.ROWID"; + let mdataColsSql = ", t.ROWID"; - if (this.mdata_cols) { + if (this.mdata_cols) { if (this.mdata_cols.length > 3) { - throw new Error("Exceeds the max number of columns you can request for metadata."); + throw new Error( + "Exceeds the max number of columns you can request for metadata." + ); } - + // **First, check if the column names are valid identifiers** for (const col of this.mdata_cols) { - if (!this.isValidIdentifier(col)) { - throw new Error(`Invalid column name in mdata_cols: ${col}`); - } + if (!this.isValidIdentifier(col)) { + throw new Error(`Invalid column name in mdata_cols: ${col}`); + } } // Execute a query to get column data types const colSql = ` - SELECT COLUMN_NAME, DATA_TYPE - FROM ALL_TAB_COLUMNS - WHERE OWNER = :ownername AND TABLE_NAME = :tablename - `; + SELECT COLUMN_NAME, DATA_TYPE + FROM ALL_TAB_COLUMNS + WHERE OWNER = :ownername AND TABLE_NAME = :tablename + `; const colBinds = { - ownername: this.owner.toUpperCase(), - tablename: this.loadFrom.toUpperCase(), + ownername: this.owner.toUpperCase(), + tablename: this.loadFrom.toUpperCase(), }; - const colResult = await this.conn.execute<{ COLUMN_NAME: string; DATA_TYPE: string }>( - colSql, - colBinds, - { outFormat: oracledb.OUT_FORMAT_OBJECT } - ); + const colResult = await this.conn.execute<{ + COLUMN_NAME: string; + DATA_TYPE: string; + }>(colSql, colBinds, { outFormat: oracledb.OUT_FORMAT_OBJECT }); const colRows = colResult.rows; if (!colRows) { - throw new Error("Failed to retrieve column information"); + throw new Error("Failed to retrieve column information"); } const colTypes: Record = {}; for (const row of colRows) { - const colName = row.COLUMN_NAME; - const dataType = row.DATA_TYPE; - colTypes[colName] = dataType; + const colName = row.COLUMN_NAME; + const dataType = row.DATA_TYPE; + colTypes[colName] = dataType; } for (const col of this.mdata_cols) { - if (!this.isValidIdentifier(col)) { - throw new Error(`Invalid column name in mdata_cols: ${col}`); - } - - const dataType = colTypes[col]; - if (!dataType) { - throw new Error(`Column ${col} not found in table ${this.loadFrom}`); - } - - if ( - ![ - "NUMBER", - "BINARY_DOUBLE", - "BINARY_FLOAT", - "LONG", - "DATE", - "TIMESTAMP", - "VARCHAR2", - ].includes(dataType) - ) { - throw new Error(`The datatype for the column ${col} is not supported`); - } + if (!this.isValidIdentifier(col)) { + throw new Error(`Invalid column name in mdata_cols: ${col}`); + } + + const dataType = colTypes[col]; + if (!dataType) { + throw new Error( + `Column ${col} not found in table ${this.loadFrom}` + ); + } + + if ( + ![ + "NUMBER", + "BINARY_DOUBLE", + "BINARY_FLOAT", + "LONG", + "DATE", + "TIMESTAMP", + "VARCHAR2", + ].includes(dataType) + ) { + throw new Error( + `The datatype for the column ${col} is not supported` + ); + } } for (const col of this.mdata_cols) { - mdataColsSql += `, t.${col}`; + mdataColsSql += `, t.${col}`; } - } + } - const mainSql = ` - SELECT dbms_vector_chain.utl_to_text(t.${this.colname}, json(:params)) AS MDATA, - dbms_vector_chain.utl_to_text(t.${this.colname}) AS TEXT - ${mdataColsSql} - FROM ${this.owner}.${this.loadFrom} t - `; + const mainSql = ` + SELECT dbms_vector_chain.utl_to_text(t.${this.colname}, json(:params)) AS MDATA, + dbms_vector_chain.utl_to_text(t.${this.colname}) AS TEXT + ${mdataColsSql} + FROM ${this.owner}.${this.loadFrom} t + `; - const mainBinds = { + const mainBinds = { params: JSON.stringify(m_params), - }; + }; - const options = { + const options = { outFormat: oracledb.OUT_FORMAT_OBJECT, - }; + }; - // Get the username - const userResult = await this.conn.execute<{ USER: string }>('SELECT USER FROM dual'); - const username = userResult.rows?.[0]?.USER || "unknown_user"; + // Get the username + const userResult = await this.conn.execute<{ USER: string }>( + "SELECT USER FROM dual" + ); + const username = userResult.rows?.[0]?.USER || "unknown_user"; - // Execute the main SQL query - const result = await this.conn.execute(mainSql, mainBinds, options); - const rows = result.rows as TableRow[]; + // Execute the main SQL query + const result = await this.conn.execute(mainSql, mainBinds, options); + const rows = result.rows as TableRow[]; - if (rows) { + if (rows) { for (const row of rows) { - let metadata: Record = {}; - - if (row["MDATA"]) { - const data = (await (row["MDATA"] as unknown as oracledb.Lob).getData()).toString(); - if ( - data.trim().startsWith("") - ) { - const parser = new ParseOracleDocMetadata(); - parser.parse(data); - metadata = { ...metadata, ...parser.getMetadata() }; - } + let metadata: Record = {}; + + if (row.MDATA) { + const data = ( + await (row.MDATA as unknown as oracledb.Lob).getData() + ).toString(); + if ( + data.trim().startsWith("") + ) { + const parser = new ParseOracleDocMetadata(); + parser.parse(data); + metadata = { ...metadata, ...parser.getMetadata() }; } + } - const docId = OracleDocReader.generateObjectId( - `${username}$${this.owner}$${this.loadFrom}$${this.colname}$${row["ROWID"]}` - ); + const docId = OracleDocReader.generateObjectId( + `${username}$${this.owner}$${this.loadFrom}$${this.colname}$${row.ROWID}` + ); - metadata["_oid"] = docId; - metadata["_rowid"] = row["ROWID"]; + metadata._oid = docId; + metadata._rowid = row.ROWID; - if (this.mdata_cols) { - for (const colName of this.mdata_cols) { - metadata[colName] = row[colName]; - } + if (this.mdata_cols) { + for (const colName of this.mdata_cols) { + metadata[colName] = row[colName]; } + } - const text = row["TEXT"] as string; + const text = row.TEXT as string; - if (text === null || text === undefined) { - results.push(new Document({ pageContent: "", metadata })); - } else { - results.push(new Document({ pageContent: text, metadata })); - } + if (text === null || text === undefined) { + documents.push(new Document({ pageContent: "", metadata })); + } else { + documents.push(new Document({ pageContent: text, metadata })); + } } + } + break; + } catch (ex) { + console.error(`An exception occurred: ${ex}`); + throw ex; } - - return results; - } catch (ex) { - console.error(`An exception occurred: ${ex}`); - throw ex; + default: + throw new Error("Invalid type to load from"); } + return documents; + } + + private isValidIdentifier(identifier: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier); } - } diff --git a/package.json b/package.json index 09f148fa452c..d647515dcbcf 100644 --- a/package.json +++ b/package.json @@ -46,16 +46,13 @@ "license": "MIT", "devDependencies": { "@tsconfig/recommended": "^1.0.2", - "@types/jest": "^29.5.14", - "@types/oracledb": "^6", + "@types/jest": "^29.5.3", "@types/semver": "^7", "commander": "^11.1.0", "dotenv": "^16.0.3", - "jest": "^29.7.0", "lint-staged": "^13.1.1", "prettier": "^2.8.3", "semver": "^7.5.4", - "ts-jest": "^29.2.5", "turbo": "^1.13.3", "typescript": "~5.1.6" }, @@ -72,8 +69,5 @@ "eslint --cache --fix" ], "*.md": "prettier --config .prettierrc --write" - }, - "dependencies": { - "oracledb": "^6.7.0" } } diff --git a/yarn.lock b/yarn.lock index 5d565c6d8c9c..c0139a751072 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11758,11 +11758,11 @@ __metadata: "@types/d3-dsv": ^3.0.7 "@types/flat": ^5.0.2 "@types/html-to-text": ^9 - "@types/jest": ^29.5.14 "@types/jsdom": ^21.1.1 "@types/jsonwebtoken": ^9 "@types/lodash": ^4 "@types/mozilla-readability": ^0.2.1 + "@types/oracledb": ^6 "@types/pdf-parse": ^1.1.1 "@types/pg": ^8.11.0 "@types/pg-copy-streams": ^1.2.2 @@ -11819,12 +11819,13 @@ __metadata: hdb: 0.19.8 hnswlib-node: ^3.0.0 html-to-text: ^9.0.5 + htmlparser2: ^9.1.0 ibm-cloud-sdk-core: ^5.0.2 ignore: ^5.2.0 interface-datastore: ^8.2.11 ioredis: ^5.3.2 it-all: ^3.0.4 - jest: ^29.7.0 + jest: ^29.5.0 jest-environment-node: ^29.6.4 js-yaml: ^4.1.0 jsdom: ^22.1.0 @@ -11842,6 +11843,7 @@ __metadata: notion-to-md: ^3.1.0 officeparser: ^4.0.4 openai: "*" + oracledb: ^6.7.0 pdf-parse: 1.1.1 pg: ^8.11.0 pg-copy-streams: ^6.0.5 @@ -11857,7 +11859,7 @@ __metadata: rollup: ^3.19.1 sonix-speech-recognition: ^2.1.1 srt-parser-2: ^1.2.3 - ts-jest: ^29.2.5 + ts-jest: ^29.1.0 typeorm: ^0.3.20 typescript: ~5.1.6 typesense: ^1.5.3 @@ -19313,13 +19315,13 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^29.5.14": - version: 29.5.14 - resolution: "@types/jest@npm:29.5.14" +"@types/jest@npm:^29.5.3": + version: 29.5.3 + resolution: "@types/jest@npm:29.5.3" dependencies: expect: ^29.0.0 pretty-format: ^29.0.0 - checksum: 18dba4623f26661641d757c63da2db45e9524c9be96a29ef713c703a9a53792df9ecee9f7365a0858ddbd6440d98fe6b65ca67895ca5884b73cbc7ffc11f3838 + checksum: e36bb92e0b9e5ea7d6f8832baa42f087fc1697f6cd30ec309a07ea4c268e06ec460f1f0cfd2581daf5eff5763475190ec1ad8ac6520c49ccfe4f5c0a48bfa676 languageName: node linkType: hard @@ -22529,7 +22531,7 @@ __metadata: languageName: node linkType: hard -"bs-logger@npm:0.x, bs-logger@npm:^0.2.6": +"bs-logger@npm:0.x": version: 0.2.6 resolution: "bs-logger@npm:0.2.6" dependencies: @@ -22979,7 +22981,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.2": +"chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -25983,17 +25985,6 @@ __metadata: languageName: node linkType: hard -"ejs@npm:^3.1.10": - version: 3.1.10 - resolution: "ejs@npm:3.1.10" - dependencies: - jake: ^10.8.5 - bin: - ejs: bin/cli.js - checksum: ce90637e9c7538663ae023b8a7a380b2ef7cc4096de70be85abf5a3b9641912dde65353211d05e24d56b1f242d71185c6d00e02cb8860701d571786d92c71f05 - languageName: node - linkType: hard - "electron-to-chromium@npm:^1.4.284": version: 1.4.322 resolution: "electron-to-chromium@npm:1.4.322" @@ -30315,6 +30306,18 @@ __metadata: languageName: node linkType: hard +"htmlparser2@npm:^9.1.0": + version: 9.1.0 + resolution: "htmlparser2@npm:9.1.0" + dependencies: + domelementtype: ^2.3.0 + domhandler: ^5.0.3 + domutils: ^3.1.0 + entities: ^4.5.0 + checksum: e5f8d5193967e4a500226f37bdf2c0f858cecb39dde14d0439f24bf2c461a4342778740d988fbaba652b0e4cb6052f7f2e99e69fc1a329a86c629032bb76e7c8 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.0, http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -32002,20 +32005,6 @@ __metadata: languageName: node linkType: hard -"jake@npm:^10.8.5": - version: 10.9.2 - resolution: "jake@npm:10.9.2" - dependencies: - async: ^3.2.3 - chalk: ^4.0.2 - filelist: ^1.0.4 - minimatch: ^3.1.2 - bin: - jake: bin/cli.js - checksum: f2dc4a086b4f58446d02cb9be913c39710d9ea570218d7681bb861f7eeaecab7b458256c946aeaa7e548c5e0686cc293e6435501e4047174a3b6a504dcbfcaae - languageName: node - linkType: hard - "javascript-stringify@npm:^2.0.1": version: 2.1.0 resolution: "javascript-stringify@npm:2.1.0" @@ -32759,7 +32748,7 @@ __metadata: languageName: node linkType: hard -"jest@npm:^29.5.0, jest@npm:^29.7.0": +"jest@npm:^29.5.0": version: 29.7.0 resolution: "jest@npm:29.7.0" dependencies: @@ -33431,17 +33420,13 @@ __metadata: resolution: "langchainjs@workspace:." dependencies: "@tsconfig/recommended": ^1.0.2 - "@types/jest": ^29.5.14 - "@types/oracledb": ^6 + "@types/jest": ^29.5.3 "@types/semver": ^7 commander: ^11.1.0 dotenv: ^16.0.3 - jest: ^29.7.0 lint-staged: ^13.1.1 - oracledb: ^6.7.0 prettier: ^2.8.3 semver: ^7.5.4 - ts-jest: ^29.2.5 turbo: ^1.13.3 typescript: ~5.1.6 languageName: unknown @@ -34284,7 +34269,7 @@ __metadata: languageName: node linkType: hard -"make-error@npm:1.x, make-error@npm:^1.3.6": +"make-error@npm:1.x": version: 1.3.6 resolution: "make-error@npm:1.3.6" checksum: b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402 @@ -42108,43 +42093,6 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^29.2.5": - version: 29.2.5 - resolution: "ts-jest@npm:29.2.5" - dependencies: - bs-logger: ^0.2.6 - ejs: ^3.1.10 - fast-json-stable-stringify: ^2.1.0 - jest-util: ^29.0.0 - json5: ^2.2.3 - lodash.memoize: ^4.1.2 - make-error: ^1.3.6 - semver: ^7.6.3 - yargs-parser: ^21.1.1 - peerDependencies: - "@babel/core": ">=7.0.0-beta.0 <8" - "@jest/transform": ^29.0.0 - "@jest/types": ^29.0.0 - babel-jest: ^29.0.0 - jest: ^29.0.0 - typescript: ">=4.3 <6" - peerDependenciesMeta: - "@babel/core": - optional: true - "@jest/transform": - optional: true - "@jest/types": - optional: true - babel-jest: - optional: true - esbuild: - optional: true - bin: - ts-jest: cli.js - checksum: d60d1e1d80936f6002b1bb27f7e062408bc733141b9d666565503f023c340a3196d506c836a4316c5793af81a5f910ab49bb9c13f66e2dc66de4e0f03851dbca - languageName: node - linkType: hard - "ts-md5@npm:^1.3.1": version: 1.3.1 resolution: "ts-md5@npm:1.3.1"