diff --git a/.gitignore b/.gitignore index 1f4706a..311aefb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules .devcontainer dist -.vscode \ No newline at end of file +.vscode +.rollup_cache \ No newline at end of file diff --git a/README.md b/README.md index cf7068d..3cc06d0 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,34 @@ -# Skytable NodeJS Driver +# `skytable-node`: Skytable driver for NodeJS -This is the skeleton template repository for Skytable's NodeJS driver. You can [track the implementation here](https://github.com/skytable/skytable/issues/324)! +## Getting started -Tasks: -- [ ] Implement a basic Skytable client driver for NodeJS -- [ ] Functionality: - - [ ] Implement the full Skyhash protocol - - [ ] Be able to send and receive queries and decode them ergonomically (into appropriate objects and/or types) - - [ ] Should have a way to use TLS +```shell +yarn add skytable-node +``` -> For contributors: You might find the [Rust client driver](https://github.com/skytable/client-rust) to be a good reference. -> We're here to help! Please jump into our [Discord Community](https://discord.gg/QptWFdx); we're here to mentor you. +## Example + +```js +const { Config, Query } = require('skytable-node'); +const cfg = new Config("root", "password"); + +async function main() { + let db; + try { + db = await cfg.connect(); + console.log(await db.query(new Query("sysctl report status"))); + } catch (e) { + console.error(e); + process.exit(1); + } finally { + if (db) { + await db.disconnect(); + } + } +} + +main() +``` ## License diff --git a/__tests__/dcl.spec.ts b/__tests__/dcl.spec.ts new file mode 100644 index 0000000..29cab59 --- /dev/null +++ b/__tests__/dcl.spec.ts @@ -0,0 +1,20 @@ +import { Config, Query } from '../src'; + +const cfg = new Config('root', 'password'); + +async function main() { + let db; + try { + db = await cfg.connect(); + console.log(await db.query(new Query('sysctl report status'))); + } catch (e) { + console.error(e); + process.exit(1); + } finally { + if (db) { + await db.disconnect(); + } + } +} + +main(); diff --git a/__tests__/ddl.spec.ts b/__tests__/ddl.spec.ts deleted file mode 100644 index 487c245..0000000 --- a/__tests__/ddl.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { getDBConfig, getSpace, getTable } from './utils'; - -const testSpace = 'ddltestspace'; - -describe('DDL', () => { - let db: any; - const dbConfig = getDBConfig(); - - beforeAll(async () => { - db = await dbConfig.connect(); - }); - - afterAll(async () => { - dbConfig.disconnect(); - }); - - it('CREATE SPACE', async () => { - const spaceName = `${testSpace + Date.now()}`; - try { - expect(await db.query(`CREATE SPACE ${spaceName}`)).toBe(null); - } finally { - await db.query(`DROP SPACE ALLOW NOT EMPTY ${spaceName}`); - } - }); - - // FIXME need to fix. why need 'ALTER SPACE'? - // it('ALTER SPACE', async () => { - // const spaceName = `${testSpace + Date.now()}`; - // try { - // await db.query(`CREATE SPACE IF NOT EXISTS ${spaceName} WITH { property_name: ? }`, 123); - - // expect(await db.query(`ALTER SPACE ${spaceName} WITH { property_name: ? }`, 456)).toBe(null); - // } finally { - // await db.query(`DROP SPACE ALLOW NOT EMPTY ${spaceName}`); - // } - // }) - - it('CREATE MODEL', async () => { - const [space, drop] = await getSpace(db, `${testSpace + Date.now()}`); - const tableName = `${space}.testTable${Date.now()}`; - - try { - await db.query(`CREATE MODEL ${tableName}(id: string, name: string)`); - - expect( - await db.query( - `CREATE MODEL IF NOT EXISTS ${tableName}(id: string, name: string)`, - ), - ).toBe(false); - } finally { - await drop(); - } - }); - - it('ALTER MODEL', async () => { - const [tableName, drop] = await getTable(db); - - try { - await db.query(`CREATE MODEL ${tableName}(id: string, name: string)`); - await db.query(`ALTER MODEL ${tableName} ADD field { type: uint8 }`); - await db.query( - `ALTER MODEL ${tableName} ADD ( first_field { type: string }, second_field { type: binary } )`, - ); - - await db.query(`ALTER MODEL ${tableName} UPDATE field { type: uint64 }`); - await db.query( - `ALTER MODEL ${tableName} REMOVE (first_field, second_field)`, - ); - } finally { - await drop(); - } - }); -}); diff --git a/__tests__/dml.spec.ts b/__tests__/dml.spec.ts deleted file mode 100644 index 4640e4b..0000000 --- a/__tests__/dml.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { Rows } from '../src'; -import { getDBConfig, getTable } from './utils'; - -describe('DML', () => { - let db: any; - const dbConfig = getDBConfig(); - - beforeAll(async () => { - db = await dbConfig.connect(); - }); - - afterAll(async () => { - dbConfig.disconnect(); - }); - - it('null type', async () => { - const [tableName, drop] = await getTable(db); - try { - await db.query( - `CREATE MODEL ${tableName}(username: string, null email_id: string)`, - ); - - await db.query(`INSERT INTO ${tableName}(?, ?)`, 'test', null); - - expect( - await db.query( - `SELECT username,email_id FROM ${tableName} WHERE username = ?`, - 'test', - ), - ).toEqual(['test', null]); - } finally { - await drop(); - } - }); - - it('int number type', async () => { - const [tableName, drop] = await getTable(db); - - try { - await db.query( - `CREATE MODEL ${tableName}(u8: uint8, u16: uint16, u32: uint32, u64: uint64)`, - ); - - await db.query( - `INSERT INTO ${tableName}(?, ?, ?, ?)`, - 1, - 2, - 3312321, - BigInt(478787872837218382), - ); - - // TODO why is the uint8 in bigint - expect( - await db.query(`SELECT * FROM ${tableName} WHERE u8 = ?`, 1), - ).toEqual([BigInt(1), 2, 3312321, BigInt(478787872837218382)]); - } finally { - await drop(); - } - }); - - it('list type', async () => { - const [tableName, drop] = await getTable(db); - - try { - await db.query( - `CREATE MODEL ${tableName}(id: uint64, list: list { type: string} )`, - ); - - await db.query(`INSERT INTO ${tableName}(?, [?])`, 1, 'test'); - - expect( - await db.query(`SELECT * FROM ${tableName} WHERE id = ?`, 1), - ).toEqual([BigInt(1), ['test']]); - } finally { - await drop(); - } - }); - - it('binary type', async () => { - const [tableName, drop] = await getTable(db); - - try { - await db.query(`CREATE MODEL ${tableName}(id: uint64, binary: binary )`); - - await db.query(`INSERT INTO ${tableName}(?, ?)`, 1, Buffer.from('test')); - - expect( - await db.query(`SELECT * FROM ${tableName} WHERE id = ?`, 1), - ).toEqual([BigInt(1), Buffer.from('test')]); - } finally { - await drop(); - } - }); - - it('Multirow', async () => { - const [tableName, drop] = await getTable(db); - - try { - await db.query(`CREATE MODEL ${tableName}(id: string, name: string )`); - for (let i = 0; i < 10; i++) { - await db.query(`INSERT INTO ${tableName}(?, ?)`, String(i), 'test'); - } - const rows = (await db.query( - `SELECT ALL * FROM ${tableName} LIMIT ?`, - 10, - )) as Rows; - - expect( - rows.sort((row1, row2) => - Number(row1[0] as string) > Number(row2[0] as string) ? 1 : -1, - ), - ).toEqual([ - ...Array.from({ length: 10 }).map((_, index) => [ - String(index), - 'test', - ]), - ]); - } finally { - await drop(); - } - }); -}); diff --git a/__tests__/utils.ts b/__tests__/utils.ts deleted file mode 100644 index 9811fbb..0000000 --- a/__tests__/utils.ts +++ /dev/null @@ -1,25 +0,0 @@ -import Config from '../src'; - -export function getDBConfig() { - return new Config('root', 'admin123456123456', '127.0.0.1', 2003); -} - -export async function getSpace( - db: any, - space = 'testspace', -): Promise<[string, Function]> { - await db.query(`CREATE SPACE IF NOT EXISTS ${space}`); - - await db.query(`USE ${space}`); - - return [ - space, - async () => await db.query(`DROP SPACE ALLOW NOT EMPTY ${space}`), - ]; -} - -export async function getTable(db: any): Promise<[string, Function]> { - const [space, drop] = await getSpace(db, `testTable${Date.now()}Space`); - - return [`${space}.testTable${Date.now()}`, drop as Function]; -} diff --git a/examples/simple.js b/examples/simple.js new file mode 100644 index 0000000..b6f1104 --- /dev/null +++ b/examples/simple.js @@ -0,0 +1,19 @@ +const { Config, Query } = require('skytable-node'); +const cfg = new Config('root', 'password'); + +async function main() { + let db; + try { + db = await cfg.connect(); + console.log(await db.query(new Query('sysctl report status'))); + } catch (e) { + console.error(e); + process.exit(1); + } finally { + if (db) { + await db.disconnect(); + } + } +} + +main(); diff --git a/examples/simple.ts b/examples/simple.ts deleted file mode 100644 index 6a0c2af..0000000 --- a/examples/simple.ts +++ /dev/null @@ -1,36 +0,0 @@ -import Config, { type Row } from '../src'; - -async function main() { - const config = new Config('root', 'admin123456123456', '127.0.0.1', 2003); - const spaceName = `testSpace`; - const tableName = `${spaceName}.users`; - const db = await config.connect(); - - try { - await db.query(`create space IF NOT EXISTS ${spaceName}`); - await db.query(`use ${spaceName}`); - await db.query( - `CREATE MODEL ${tableName}(username: string, password: string, null email_id: string)`, - ); - await db.query( - `INSERT INTO ${tableName}(?, ?, ?)`, - 'test', - 'password', - null, - ); - const row = await db.query( - `SELECT * FROM ${tableName} WHERE username = ?`, - 'test', - ); - const [username, password, email_id] = row as Row; - console.assert(username === 'test'); - console.assert(password === 'password'); - console.assert(email_id == null); - } catch (e) { - console.error(e); - } finally { - config.disconnect(); - } -} - -main(); diff --git a/package.json b/package.json index 5771fc8..3be511a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skytable-node", - "version": "0.1.2", + "version": "0.2.0", "main": "dist/cjs/index.js", "types": "dist/cjs/index.d.ts", "module": "dist/esm/index.mjs", diff --git a/src/config.ts b/src/config.ts index a89b834..b3b7796 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,38 +1,15 @@ -import { - connectionWrite, - createConnection, - createConnectionTls, -} from './connection'; -import { createDB } from './skytable'; -import { bufferToHandshakeResult, getClientHandshake } from './protocol'; -import type { - ConnectionOptions as ConnectionTLSOptions, - TLSSocket, -} from 'node:tls'; -import { Socket } from 'node:net'; +import { ConnectionOptions } from 'tls'; +import { Connection, createTcp, createTls } from './connection'; -/** - * Configuration for a client connection (single node) - */ export class Config { private username: string; private password: string; private host: string; private port: number; - private connection: Socket | TLSSocket | undefined; - - /** - * Create a new configuration - * - * @param username Set the username for authenticating with Skytable - * @param password Set the password for authenticating with Skytable - * @param host Set the host to connect to Skytable (defaults to `127.0.0.1`) - * @param port Set the port to connect to Skytable (defaults to 2003) - */ constructor( username: string, password: string, - host: string = 'localhost', + host: string = '127.0.0.1', port: number = 2003, ) { this.username = username; @@ -40,7 +17,6 @@ export class Config { this.host = host; this.port = port; } - /** * Get the set username * @returns Username set in this configuration @@ -51,7 +27,7 @@ export class Config { /** * Get the set password - * @returns Username set in this configuration + * @returns password set in this configuration */ getPassword(): string { return this.password; @@ -59,7 +35,7 @@ export class Config { /** * Get the set host - * @returns Username set in this configuration + * @returns host set in this configuration */ getHost(): string { return this.host; @@ -67,55 +43,32 @@ export class Config { /** * Get the set port - * @returns Username set in this configuration + * @returns port set in this configuration */ getPort(): number { return this.port; } /** - * Get the connection - * @returns The current connection - */ - getConnection() { - return this.connection; - } - - /** - * connect to Skytable - */ - async connect() { - const socket = await createConnection({ - port: this.port, - host: this.host, - }); - const data = await connectionWrite(socket, getClientHandshake(this)); - await bufferToHandshakeResult(data); - this.connection = socket; - return createDB(socket); - } - - /** - * connect to Skytable + * Get a TCP connection to the database + * + * @returns a Skyhash/TCP connection */ - async connectTLS(options: ConnectionTLSOptions) { - const socket = await createConnectionTls({ - port: this.port, - host: this.host, - ...options, - }); - const data = await connectionWrite(socket, getClientHandshake(this)); - await bufferToHandshakeResult(data); - this.connection = socket; - return createDB(socket); + async connect(): Promise { + const con = await createTcp(this); + await con._handshake(this); + return con; } /** - * disconnect from Skytable + * Get a TLS connection to the database + * + * @param tlsOpts TLS options + * @returns a Skyhash/TLS connection */ - async disconnect() { - if (this.connection) { - this.connection.destroySoon(); - } + async connectTls(tlsOpts: ConnectionOptions): Promise { + const con = await createTls(this, tlsOpts); + await con._handshake(this); + return con; } } diff --git a/src/connection.ts b/src/connection.ts index 157ef8b..8bddefe 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -1,54 +1,104 @@ -import { connect as connectTcp, Socket, NetConnectOpts } from 'node:net'; +/** + * WARNING + * --- + * This is an incomplete implementation for the buffer read from the socket. + * + * It needs to be fixed so that if sufficient data is not buffered, we should enter a retry + * read loop (as is obvious). + * + * TODO(@ohsayan) + */ + +import { Socket, connect as connectTcp } from 'net'; import { - connect as connectTcpTLS, TLSSocket, - ConnectionOptions as ConnectionTLSOptions, -} from 'node:tls'; + connect as connectTcpTLS, + ConnectionOptions as TlsOptions, +} from 'tls'; +import { Query } from './query'; +import { + NEWLINE, + responseDecode, + Response, + handshakeEncode, + handshakeDecode, +} from './protocol'; +import { Config } from './config'; +import { connectionWrite } from './utils'; -export function createConnection(options: NetConnectOpts): Promise { +export function createTcp(c: Config): Promise { return new Promise((resolve, reject) => { - const conn = connectTcp(options); + const conn = connectTcp({ + port: c.getPort(), + host: c.getHost(), + }); conn.once('connect', () => { - resolve(conn); + resolve(new Connection(conn)); }); conn.once('error', (error) => { - console.error(`createConnection error: ${error.message}`); + console.error(`tcp connection failed with error: ${error.message}`); reject(error); }); }); } - -export function createConnectionTls( - options: ConnectionTLSOptions, -): Promise { +export function createTls(c: Config, tlsOpts: TlsOptions): Promise { return new Promise((resolve, reject) => { - const conn = connectTcpTLS(options); + const conn = connectTcpTLS(tlsOpts); conn.once('connect', () => { - resolve(conn); + resolve(new Connection(conn)); }); conn.once('error', (error) => { - console.error(`createConnection error: ${error.message}`); + console.error(`tls connection failed with error: ${error.message}`); reject(error); }); }); } -export function connectionWrite( - connect: Socket | TLSSocket, - buffer: Buffer | string, -): Promise { - return new Promise((resolve, reject) => { - connect.write(buffer, (writeError) => { - if (writeError) { - reject(writeError); - return; - } - connect.once('data', (data) => { - resolve(data); - }); - connect.once('error', (err) => { - reject(err); - }); +export class Connection { + private socket: TLSSocket | Socket; + constructor(c: TLSSocket | Socket) { + this.socket = c; + } + async _handshake(c: Config): Promise { + const data = await connectionWrite(this.socket, handshakeEncode(c)); + await handshakeDecode(data); + } + public query(q: Query): Promise { + return new Promise((resolve, reject) => { + // Set listeners + const dataListener = (buf: Buffer) => { + this.socket.removeListener('error', errorListener); + resolve(responseDecode(buf)); + }; + const errorListener = (e: Error) => { + this.socket.removeListener('data', dataListener); + reject(e); + }; + this.socket.once('data', dataListener); + this.socket.once('error', errorListener); + // Calculate dataframe size + const queryBuffer = q.getQuery(); + const paramsBuffer = q._getParamBuffer(); + const qWindow = queryBuffer.length.toString(); + // Calculate packet size + const packetSize = + qWindow.length + + 1 + + queryBuffer.length + + paramsBuffer.reduce((acc, buf) => acc + buf.length, 0); + // send data + this.socket.write('S'); + this.socket.write(packetSize.toString()); + this.socket.write(NEWLINE); + this.socket.write(qWindow); + this.socket.write(NEWLINE); + this.socket.write(queryBuffer); + paramsBuffer.forEach((buf) => this.socket.write(buf)); }); - }); + } + public async disconnect() { + if (this.socket) { + this.socket.destroySoon(); + } + } } diff --git a/src/index.ts b/src/index.ts index 38d07c1..7beff7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export { Config as default } from './config'; -export * from './skytable'; -export * from './protocol'; -export * from './query'; +export { Query, Parameter, SignedInt } from './query'; +export { Connection } from './connection'; +export { Config } from './config'; +export { SimpleValue, Value, Row, Rows, Empty } from './protocol'; diff --git a/src/protocol.ts b/src/protocol.ts index a89656f..29cb49a 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -1,17 +1,153 @@ import { Config } from './config'; -import type { Column, QueryResult, Row, Rows, SQParam } from './skytable'; -import { Query } from './query'; +import { Parameter, Query, isSQParam } from './query'; +import { isFloat } from './utils'; -const PARAMS_TYPE = { - NULL: '\x00', - BOOLEAN: '\x01', - UINT: '\x02', - SINT: '\x03', - FLOAT: '\x04', - BINARY: '\x05', - STRING: '\x06', +/* + Handshake +*/ + +const HANDSHAKE_RESULT = { + SUCCESS: 'H00', + ERROR: 'H01', +}; + +export function handshakeEncode(config: Config): string { + const username = config.getUsername(); + const password = config.getPassword(); + return [ + 'H\x00\x00\x00\x00\x00', + username.length, + '\n', + password.length, + '\n', + username, + password, + ].join(''); +} + +export function handshakeDecode(buffer: Buffer): Promise { + return new Promise((resolve, reject) => { + const [h, c1, c2, msg] = Array.from(buffer.toJSON().data); + const code = [String.fromCharCode(h), c1, c2].join(''); + if (code === HANDSHAKE_RESULT.SUCCESS) { + return resolve(); + } + reject(new Error(`handshake error code ${code}, msg: ${msg}`)); + }); +} + +/* + Query implementations +*/ + +export const NEWLINE = new Uint8Array([0x0a]); +const STATICALLY_ENCODED_BOOL_FALSE = new Uint8Array([0x00]); +const STATICALLY_ENCODED_BOOL_TRUE = new Uint8Array([0x01]); + +export const PARAMS_TYPE = { + NULL: new Uint8Array([0x00]), + BOOLEAN: new Uint8Array([0x01]), + UINT: new Uint8Array([0x02]), + SINT: new Uint8Array([0x03]), + FLOAT: new Uint8Array([0x04]), + BINARY: new Uint8Array([0x05]), + STRING: new Uint8Array([0x06]), }; +export function queryEncodeParams(query: Query, param: Parameter): void { + switch (typeof param) { + case 'boolean': { + query + ._getParamBuffer() + .push( + Buffer.concat([ + PARAMS_TYPE.BOOLEAN, + param + ? STATICALLY_ENCODED_BOOL_TRUE + : STATICALLY_ENCODED_BOOL_FALSE, + ]), + ); + break; + } + case 'number': { + query + ._getParamBuffer() + .push( + Buffer.concat([ + isFloat(param) + ? PARAMS_TYPE.FLOAT + : param < 0 + ? PARAMS_TYPE.SINT + : PARAMS_TYPE.UINT, + Buffer.from(param.toString()), + NEWLINE, + ]), + ); + break; + } + case 'bigint': { + query + ._getParamBuffer() + .push( + Buffer.concat([ + param < 0 ? PARAMS_TYPE.SINT : PARAMS_TYPE.UINT, + Buffer.from(param.toString()), + NEWLINE, + ]), + ); + break; + } + case 'string': { + query + ._getParamBuffer() + .push(Buffer.concat([PARAMS_TYPE.STRING, Buffer.from(param), NEWLINE])); + break; + } + case 'object': { + if (param === null) { + query._getParamBuffer().push(PARAMS_TYPE.NULL); + break; + } else if (param instanceof Buffer) { + query + ._getParamBuffer() + .push( + Buffer.concat([ + PARAMS_TYPE.BINARY, + Buffer.from(param.length.toString()), + NEWLINE, + param, + ]), + ); + break; + } else if (isSQParam(param)) { + return query._incrQueryCountBy( + param.encodeUnsafe(query._getParamBuffer()), + ); + } + } + default: + throw new TypeError(`unsupported type: ${typeof param}, val: ${param}`); + } + query._incrQueryCountBy(1); +} + +/* + response implementations +*/ + +export type SimpleValue = null | boolean | number | bigint | Buffer | string; +export type Value = SimpleValue | Value[]; +export type Row = Value[]; +export type Rows = Row[]; +export type Response = Value | Row | Rows | Empty; + +/** + * An empty response, usually indicative of a succesful action (much like HTTP 200) + */ +export class Empty { + constructor() {} +} + const RESPONSES_RESULT = { NULL: 0, BOOL: 1, @@ -34,64 +170,6 @@ const RESPONSES_RESULT = { MULTIROW: 0x13, }; -const HANDSHAKE_RESULT = { - SUCCESS: 'H00', - ERROR: 'H01', -}; - -function isFloat(number: number | string): boolean { - return Number.isFinite(number) && !Number.isInteger(number); -} - -export function encodeParam(param: SQParam): string { - // 5 A binary blob [5\n] - if (Buffer.isBuffer(param)) { - return [PARAMS_TYPE.BINARY, param.length, '\n', param.toString()].join(''); - } - // null undefined - if (param == null) { - return '\x00'; - } - switch (typeof param) { - case 'string': - return [PARAMS_TYPE.STRING, param.length, '\n', param].join(''); - case 'number': - // 2 Unsigned integer 64 - // 3 Signed integer 64 - // 4 Float A 64-bit - return [ - isFloat(param) - ? PARAMS_TYPE.FLOAT - : param < 0 - ? PARAMS_TYPE.SINT - : PARAMS_TYPE.UINT, - String(param), - '\n', - ].join(''); - case 'bigint': - return [ - param < 0 ? PARAMS_TYPE.SINT : PARAMS_TYPE.UINT, - String(param), - '\n', - ].join(''); - case 'boolean': - return [PARAMS_TYPE.BOOLEAN, Number(param) === 1 ? '\x01' : 0].join(''); - default: - throw new TypeError(`un support type: ${typeof param}, val: ${param}`); - } -} - -export function encodeParams(parameters: SQParam[]): string { - return parameters.map(encodeParam).join(''); -} - -export function encodeQuery(query: Query): Buffer { - const dataframe = `${query.getQuery()}${query.getParams().join('')}`; - const data = [query.getQueryLength(), '\n', dataframe]; - const requestData = ['S', data.join('').length, '\n', ...data]; - return Buffer.from(requestData.join(''), 'utf-8'); -} - function getFirstSplitOffset(buffer: Buffer, split = '\n'): number { for (let i = 0; i < buffer.length; i++) { if (buffer[i] === split.charCodeAt(0)) { @@ -110,7 +188,7 @@ function parseNumber( return [val, buffer.subarray(offset + 1)]; } -function parseNextBySize(size: number, buffer: Buffer): [Column[], Buffer] { +function parseNextBySize(size: number, buffer: Buffer): [Value[], Buffer] { let values = []; let nextBuffer = buffer; for (let i = 0; i < size; i++) { @@ -121,7 +199,7 @@ function parseNextBySize(size: number, buffer: Buffer): [Column[], Buffer] { return [values, nextBuffer]; } -function decodeValue(buffer: Buffer): [Column, Buffer] { +function decodeValue(buffer: Buffer): [Value, Buffer] { const type = buffer.readUInt8(0); buffer = buffer.subarray(1); switch (type) { @@ -175,7 +253,7 @@ function decodeValue(buffer: Buffer): [Column, Buffer] { return [[], buffer.subarray(sizeOffset + 1)]; } return parseNextBySize(size, buffer.subarray(sizeOffset + 1)) as [ - Column, + Value, Buffer, ]; } @@ -184,7 +262,7 @@ function decodeValue(buffer: Buffer): [Column, Buffer] { } } -export function decodeRow(buffer: Buffer): Row { +function decodeRow(buffer: Buffer): Row { const offset = getFirstSplitOffset(buffer); const columnCount = Number(buffer.subarray(0, offset).toString('utf-8')); const dataType = buffer.subarray(offset + 1); @@ -192,7 +270,7 @@ export function decodeRow(buffer: Buffer): Row { return row; } -export function decodeRows(buffer: Buffer): Rows { +function decodeRows(buffer: Buffer): Rows { const offset = getFirstSplitOffset(buffer); const rowCount = Number(buffer.subarray(0, offset).toString('utf-8')); buffer = buffer.subarray(offset + 1); @@ -211,11 +289,11 @@ export function decodeRows(buffer: Buffer): Rows { return result; } -export function decodeResponse(buffer: Buffer): QueryResult { +export function responseDecode(buffer: Buffer): Response { const type = buffer.readInt8(0); switch (type) { case RESPONSES_RESULT.EMPTY: - return null; + return new Empty(); case RESPONSES_RESULT.ROW: return decodeRow(buffer.subarray(1)); case RESPONSES_RESULT.MULTIROW: @@ -230,28 +308,3 @@ export function decodeResponse(buffer: Buffer): QueryResult { const [val] = decodeValue(buffer); return val; } - -export function getClientHandshake(config: Config): string { - const username = config.getUsername(); - const password = config.getPassword(); - return [ - 'H\x00\x00\x00\x00\x00', - username.length, - '\n', - password.length, - '\n', - username, - password, - ].join(''); -} - -export function bufferToHandshakeResult(buffer: Buffer): Promise { - return new Promise((resolve, reject) => { - const [h, c1, c2, msg] = Array.from(buffer.toJSON().data); - const code = [String.fromCharCode(h), c1, c2].join(''); - if (code === HANDSHAKE_RESULT.SUCCESS) { - return resolve(); - } - reject(new Error(`handshake error code ${code}, msg: ${msg}`)); - }); -} diff --git a/src/query.ts b/src/query.ts index 005cbde..2128e1e 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,58 +1,124 @@ -import { encodeParam } from './protocol'; -import { SQParam } from './skytable'; +import { + queryEncodeParams as encodeParam, + NEWLINE, + PARAMS_TYPE, +} from './protocol'; +import { isFloat } from './utils'; +/** + * A type that can be accepted as a parameter + */ +export type Parameter = + | null + | boolean + | bigint + | number + | Buffer + | string + | SQParam; + +export function isSQParam(obj: any): obj is SQParam { + return (obj as SQParam).encodeUnsafe != undefined; +} + +/** + * A query object represents a query that is to be sent to the + */ export class Query { private query: string; - private params: string[]; - + private params: Uint8Array[]; + private param_cnt: number; /** - * Create a new query with the base query set. You can now add (any) additional parameters. + * Create a new query * - * @param query The base query + * @param query base query + * @param params additional parameters */ - constructor(query: string) { + constructor(query: string | string, ...params: Parameter[]) { this.query = query; + this.param_cnt = 0; this.params = []; + params.forEach((param) => { + encodeParam(this, param); + }); } /** - * Get the query string - * @returns query string + * Get the base query payload + * @returns base query */ - getQuery(): string { + public getQuery(): string { return this.query; } - /** - * Get the Params - * @returns params encoded string + * Get parameter count + * @returns parameter count */ - getParams(): string[] { - return this.params; + public getParamCount(): number { + return this.params.length; } /** - * Get the number of parameters - * - * @returns Returns a count of the number of parameters + * Add a new parameter to this query + * @param param new parameter */ - public getParamCount(): number { - return this.params.length; + public pushParam(param: Parameter): void { + encodeParam(this, param); } - + // private /** - * Get the query length * - * @returns Returns the length of the query + * @returns returns the parameter buffer */ - public getQueryLength(): number { - return this.query.length; + _getParamBuffer(): Uint8Array[] { + return this.params; } /** + * Increment the parameter count by the given value * - * Add a new parameter to your query + * @param by newly added param count + */ + _incrQueryCountBy(by: number): void { + this.param_cnt += by; + } +} + +/** + * If you want to use a custom parameter, implement this interface + */ +export interface SQParam { + /** + * Encode the parameter buffer and return the number of parameters that were added. If you were encoding an + * object, you would return the number of keys that you encoded, for example. * - * @param param Query input parameter + * @param buf The raw parameter buffer */ - public pushParam(param: SQParam): void { - this.params.push(encodeParam(param)); + encodeUnsafe(buf: Uint8Array[]): number; +} + +/** + * The `SignedInt` class is meant to be used when you're trying to pass an unsigned integer into a query as a parameter. We always + * recommend doing this because the @method Query.pushParam simply checks if the value is positive or negative and then encodes it + * as either an unsigned int or a signed int, respectively. + * + * Hence, for positive values that you intended to be sent as a signed integer (as is stored in the database), use this. + * + * > TL;DR: use this to push parameters for signed integer columns + */ +export class SignedInt implements SQParam { + public value: number; + constructor(value: number) { + if (isFloat(value)) { + throw new TypeError('expected a non-floating point value'); + } + this.value = value; + } + encodeUnsafe(buf: Uint8Array[]): number { + buf.push( + Buffer.concat([ + PARAMS_TYPE.SINT, + Buffer.from(this.value.toString()), + NEWLINE, + ]), + ); + return 1; } } diff --git a/src/skytable.ts b/src/skytable.ts deleted file mode 100644 index 78a69c1..0000000 --- a/src/skytable.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Socket } from 'node:net'; -import { TLSSocket } from 'node:tls'; -import { connectionWrite } from './connection'; -import { encodeQuery, decodeResponse } from './protocol'; -import { Query } from './query'; - -export type ColumnBase = string | number | boolean | null | bigint; -export type SQParam = T | SQParam[]; -export type ColumnBinary = typeof Buffer; -export type ColumnList = T | ColumnList[]; -export type Column = - | Buffer - | ColumnBase - | ColumnBinary - | ColumnList; -export type Row = Column[]; -export type Rows = Row[]; -export type QueryResult = Column | Row | Rows; - -/** - * create a db instance - * @param connection The connection to Skytable - * @returns { query: (query: string | Query, ...params: SQParam[]) => Promise } - */ -export function createDB(connection: Socket | TLSSocket) { - const query = async ( - query: string | Query, - ...params: SQParam[] - ): Promise => { - const queryInstance = typeof query === 'string' ? new Query(query) : query; - params.forEach((param) => { - queryInstance.pushParam(param); - }); - const buffer = encodeQuery(queryInstance); - const res = await connectionWrite(connection, buffer); - return decodeResponse(res); - }; - return { - query, - }; -} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..3bff77d --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,26 @@ +import { Socket } from 'net'; +import { TLSSocket } from 'tls'; + +export function isFloat(number: number): boolean { + return Number.isFinite(number) && !Number.isInteger(number); +} + +export function connectionWrite( + connect: Socket | TLSSocket, + buffer: Buffer | string, +): Promise { + return new Promise((resolve, reject) => { + connect.write(buffer, (writeError) => { + if (writeError) { + reject(writeError); + return; + } + connect.once('data', (data) => { + resolve(data); + }); + connect.once('error', (err) => { + reject(err); + }); + }); + }); +}