Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion drivers/nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"author": "Alex Kwak <[email protected]>",
"dependencies": {
"antlr4ts": "^0.5.0-alpha.4",
"pg": ">=6.0.0"
"pg": ">=8.0.0"
},
"devDependencies": {
"@types/jest": "^29.5.14",
Expand Down
6 changes: 5 additions & 1 deletion drivers/nodejs/src/antlr4/CustomAgTypeListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ class CustomAgTypeListener implements AgtypeListener, ParseTreeListener {
}

exitIntegerValue (ctx: IntegerValueContext): void {
const value = parseInt(ctx.text)
// Use BigInt for values that exceed Number.MAX_SAFE_INTEGER to
// prevent silent precision loss with large AGE graph IDs (64-bit).
const text = ctx.text
const num = Number(text)
const value = Number.isSafeInteger(num) ? num : BigInt(text)
if (!this.pushIfArray(value)) {
this.lastValue = value
}
Expand Down
301 changes: 288 additions & 13 deletions drivers/nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,130 @@
* under the License.
*/

import { Client } from 'pg'
import { Client, QueryResult, QueryResultRow } from 'pg'
import pgTypes from 'pg-types'
import { CharStreams, CommonTokenStream } from 'antlr4ts'
import { AgtypeLexer } from './antlr4/AgtypeLexer'
import { AgtypeParser } from './antlr4/AgtypeParser'
import CustomAgTypeListener from './antlr4/CustomAgTypeListener'
import { ParseTreeWalker } from 'antlr4ts/tree'

/**
* Valid graph name pattern based on Apache AGE's naming conventions
* (Neo4j/openCypher compatible). Allows ASCII letters, digits,
* underscores, plus dots and hyphens in middle positions.
*
* DESIGN NOTE: AGE's server-side validation (name_validation.h) uses
* full Unicode ID_Start/ID_Continue character classes, accepting names
* like 'mydätabase' or 'mydঅtabase'. This driver intentionally restricts
* to ASCII-only as a security hardening measure — it reduces the attack
* surface for homoglyph and encoding-based injection vectors. Server-side
* validation remains the authoritative check for Unicode names.
*
* Start: ASCII letter or underscore
* Middle: ASCII letter, digit, underscore, dot, or hyphen
* End: ASCII letter, digit, or underscore
*/
const VALID_GRAPH_NAME = /^[A-Za-z_][A-Za-z0-9_.\-]*[A-Za-z0-9_]$/

/**
* Valid label name pattern based on Apache AGE's naming rules.
* Labels follow stricter identifier rules than graph names — dots and
* hyphens are NOT permitted in label names.
*
* Note: ASCII-only restriction (see VALID_GRAPH_NAME design note above).
*/
const VALID_LABEL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/

/**
* Valid SQL identifier pattern for column names and types in the
* query result AS clause. Standard PostgreSQL unquoted identifier rules.
*
* Note: ASCII-only restriction (see VALID_GRAPH_NAME design note above).
*/
const VALID_SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/

/**
* Validates that a graph name conforms to Apache AGE's naming conventions
* and is safe for use in SQL queries.
*
* Graph names must:
* - Be at least 3 characters and at most 63 characters
* - Start with an ASCII letter or underscore
* - Contain only ASCII letters, digits, underscores, dots, and hyphens
* - End with an ASCII letter, digit, or underscore
*
* Note: This is intentionally stricter than AGE's server-side validation
* (which accepts Unicode letters). See VALID_GRAPH_NAME design note.
*
* @param graphName - The graph name to validate
* @throws Error if the graph name is invalid
*/
function validateGraphName (graphName: string): void {
if (!graphName || typeof graphName !== 'string') {
throw new Error('Graph name must be a non-empty string')
}
if (graphName.length < 3) {
throw new Error(
`Invalid graph name: '${graphName}'. Graph names must be at least 3 characters.`
)
}
if (graphName.length > 63) {
throw new Error('Graph name must not exceed 63 characters (PostgreSQL name limit)')
}
if (!VALID_GRAPH_NAME.test(graphName)) {
throw new Error(
`Invalid graph name: '${graphName}'. Graph names must start with a letter ` +
'or underscore, may contain letters, digits, underscores, dots, and hyphens, ' +
'and must end with a letter, digit, or underscore.'
)
}
}

/**
* Validates that a label name conforms to Apache AGE's naming rules.
* Label names are stricter than graph names — only letters, digits,
* and underscores are permitted (no dots or hyphens).
*
* @param labelName - The label name to validate
* @throws Error if the label name is invalid
*/
function validateLabelName (labelName: string): void {
if (!labelName || typeof labelName !== 'string') {
throw new Error('Label name must be a non-empty string')
}
if (labelName.length > 63) {
throw new Error('Label name must not exceed 63 characters (PostgreSQL name limit)')
}
if (!VALID_LABEL_NAME.test(labelName)) {
throw new Error(
`Invalid label name: '${labelName}'. Label names must start with a letter ` +
'or underscore and contain only letters, digits, and underscores.'
)
}
}

/**
* Escapes a PostgreSQL dollar-quoted string literal by ensuring the
* cypher query does not contain the dollar-quote delimiter. If the
* default $$ delimiter conflicts, a unique tagged delimiter is used.
*
* @param cypher - The Cypher query string
* @returns An object with the delimiter and safely quoted string
*/
function cypherDollarQuote (cypher: string): { delimiter: string; quoted: string } {
if (!cypher.includes('$$')) {
return { delimiter: '$$', quoted: `$$${cypher}$$` }
}
// Generate a unique tag that doesn't appear in the cypher query
let tag = 'cypher'
let counter = 0
while (cypher.includes(`$${tag}$`)) {
tag = `cypher${counter++}`
}
return { delimiter: `$${tag}$`, quoted: `$${tag}$${cypher}$${tag}$` }
}

function AGTypeParse (input: string) {
const chars = CharStreams.fromString(input)
const lexer = new AgtypeLexer(chars)
Expand All @@ -36,21 +152,180 @@ function AGTypeParse (input: string) {
return printer.getResult()
}

async function setAGETypes (client: Client, types: typeof pgTypes) {
await client.query(`
CREATE EXTENSION IF NOT EXISTS age;
LOAD 'age';
SET search_path = ag_catalog, "$user", public;
`)
/**
* Options for setAGETypes configuration.
*/
interface SetAGETypesOptions {
/**
* If true, will attempt to CREATE EXTENSION IF NOT EXISTS age.
* Defaults to false. Set to true only if the connected user has
* sufficient privileges.
*/
createExtension?: boolean
}

/**
* Configures the pg client to properly parse AGE agtype results.
*
* This function:
* 1. Loads the AGE extension into the session
* 2. Sets the search_path to include ag_catalog
* 3. Registers the agtype type parser
*
* @param client - A connected pg Client instance
* @param types - The pg types module for registering type parsers
* @param options - Optional configuration settings
* @throws Error if AGE extension is not installed or agtype is not found
*/
async function setAGETypes (client: Client, types: typeof pgTypes, options?: SetAGETypesOptions) {
const createExtension = options?.createExtension ?? false

if (createExtension) {
await client.query('CREATE EXTENSION IF NOT EXISTS age;')
}

try {
await client.query("LOAD 'age';")
await client.query('SET search_path = ag_catalog, "$user", public;')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
throw new Error(
`Failed to load AGE extension: ${msg}. ` +
'Ensure the AGE extension is installed in the database. ' +
'You may need to run CREATE EXTENSION age; first, or pass ' +
'{ createExtension: true } to setAGETypes().'
)
}

const oidResults = await client.query(`
select typelem
from pg_type
where typname = '_agtype';`)
const oidResults = await client.query(
"SELECT typelem FROM pg_type WHERE typname = '_agtype';"
)

if (oidResults.rows.length < 1) { throw new Error() }
if (oidResults.rows.length < 1) {
throw new Error(
'AGE agtype type not found. Ensure the AGE extension is installed ' +
'and loaded in the current database. Run CREATE EXTENSION age; first, ' +
'or pass { createExtension: true } to setAGETypes().'
)
}

types.setTypeParser(oidResults.rows[0].typelem, AGTypeParse)
}

export { setAGETypes, AGTypeParse }
/**
* Column definition for Cypher query results.
*/
interface CypherColumn {
/** Column alias name */
name: string
/** Column type (defaults to 'agtype') */
type?: string
}

/**
* Executes a Cypher query safely against an AGE graph.
*
* This function validates the graph name to prevent SQL injection,
* properly quotes the Cypher query using dollar-quoting, and builds
* the required SQL wrapper.
*
* @param client - A connected pg Client instance (with AGE types set)
* @param graphName - The target graph name (must be a valid AGE graph name)
* @param cypher - The Cypher query string
* @param columns - Column definitions for the result set
* @returns The query result
* @throws Error if graphName is invalid or query fails
*
* @example
* ```typescript
* const result = await queryCypher(
* client,
* 'my_graph',
* 'MATCH (n:Person) WHERE n.name = $name RETURN n',
* [{ name: 'n' }]
* );
* ```
*/
async function queryCypher<T extends QueryResultRow = any> (
client: Client,
graphName: string,
cypher: string,
columns: CypherColumn[] = [{ name: 'result' }]
): Promise<QueryResult<T>> {
// Validate graph name against injection
validateGraphName(graphName)

// Validate column definitions
if (!columns || columns.length === 0) {
throw new Error('At least one column definition is required')
}

for (const col of columns) {
if (!col.name || typeof col.name !== 'string') {
throw new Error('Column name must be a non-empty string')
}
// Column names must be valid SQL identifiers
if (!VALID_SQL_IDENTIFIER.test(col.name)) {
throw new Error(
`Invalid column name: '${col.name}'. Column names must be valid SQL identifiers.`
)
}
if (col.type && !VALID_SQL_IDENTIFIER.test(col.type)) {
throw new Error(
`Invalid column type: '${col.type}'. Column types must be valid SQL type identifiers.`
)
}
}

// Build column list safely
const columnList = columns
.map(col => `${col.name} ${col.type ?? 'agtype'}`)
.join(', ')

// Safely dollar-quote the cypher query
const { quoted } = cypherDollarQuote(cypher)

const sql = `SELECT * FROM cypher('${graphName}', ${quoted}) AS (${columnList});`

return client.query<T>(sql)
}

/**
* Creates a new graph safely.
*
* @param client - A connected pg Client instance
* @param graphName - Name for the new graph (must be a valid AGE graph name)
* @throws Error if graphName is invalid
*/
async function createGraph (client: Client, graphName: string): Promise<void> {
validateGraphName(graphName)
await client.query(`SELECT * FROM ag_catalog.create_graph('${graphName}');`)
}

/**
* Drops an existing graph safely.
*
* @param client - A connected pg Client instance
* @param graphName - Name of the graph to drop (must be a valid AGE graph name)
* @param cascade - If true, drop dependent objects (default: false)
* @throws Error if graphName is invalid
*/
async function dropGraph (client: Client, graphName: string, cascade: boolean = false): Promise<void> {
validateGraphName(graphName)
if (typeof cascade !== 'boolean') {
throw new Error('cascade parameter must be a boolean')
}
await client.query(`SELECT * FROM ag_catalog.drop_graph('${graphName}', ${cascade});`)
}

export {
setAGETypes,
AGTypeParse,
queryCypher,
createGraph,
dropGraph,
validateGraphName,
validateLabelName,
SetAGETypesOptions,
CypherColumn
}
27 changes: 27 additions & 0 deletions drivers/nodejs/test/Agtype.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,31 @@ describe('Parsing', () => {
}))
})))
})

it('Large integer uses BigInt when exceeding MAX_SAFE_INTEGER', () => {
// 2^53 = 9007199254740992 exceeds MAX_SAFE_INTEGER (2^53 - 1)
const result = AGTypeParse('{"id": 9007199254740993, "label": "test", "properties": {}}::vertex')
const id = (result as Map<string, any>).get('id')
expect(typeof id).toBe('bigint')
expect(id).toBe(BigInt('9007199254740993'))
})

it('Safe integers remain as Number type', () => {
const result = AGTypeParse('{"id": 844424930131969, "label": "test", "properties": {}}::vertex')
const id = (result as Map<string, any>).get('id')
expect(typeof id).toBe('number')
expect(id).toBe(844424930131969)
})

it('Small integers remain as Number type', () => {
const result = AGTypeParse('42')
expect(typeof result).toBe('number')
expect(result).toBe(42)
})

it('Negative integers parsed correctly', () => {
const result = AGTypeParse('-100')
expect(typeof result).toBe('number')
expect(result).toBe(-100)
})
})
Loading
Loading