diff --git a/package.json b/package.json index ca4b45b150bd..55fa348856fe 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "chai": "4.5.0", "concurrently": "9.2.1", "cross-env": "10.1.0", - "esbuild": "0.28.0", + "esbuild": "0.28.1", "eslint": "8.57.1", "eslint-plugin-jsdoc": "61.7.1", "eslint-plugin-mocha": "10.5.0", diff --git a/packages/core/src/abstract-dialect/data-types.ts b/packages/core/src/abstract-dialect/data-types.ts index d9baf369ebd2..781055a9e700 100644 --- a/packages/core/src/abstract-dialect/data-types.ts +++ b/packages/core/src/abstract-dialect/data-types.ts @@ -2855,6 +2855,217 @@ export class TSVECTOR extends AbstractDataType { } } +export interface VectorOptions { + /** + * Optional vector dimension. + */ + dimension?: number; + /** + * Optional dialect-specific element format (for example float32, int8, binary). + */ + format?: string; +} + +/** + * Numeric typed arrays accepted as vector values. + */ +export type NumericTypedArray = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array; + +// Dialects may accept plain arrays or any number-backed typed array. The generic validator +// checks only the container shape; dialects are responsible for validating dimensions and values. +export type VectorValue = number[] | NumericTypedArray; + +/** + * The VECTOR type stores ordered numeric vectors. + * + * Availability depends on the dialect (for example Oracle, Snowflake, pgvector, ...); check your dialect’s + * documentation to confirm supported element formats and dimensions. + * + * __Fallback policy:__ + * If this type is not supported, an error will be raised. + * + * This shared base exists for dialect-specific VECTOR implementations. Dialects that can use Sequelize's + * generic {@link VectorOptions} may extend {@link VECTOR}; dialects with a different option shape may extend + * this class directly with their own options type. + * + * @example + * ```ts + * DataTypes.VECTOR + * DataTypes.VECTOR(1536) + * DataTypes.VECTOR(1536, 'float32') + * DataTypes.VECTOR({ dimension: 1024, format: 'int8' }) + * DataTypes.VECTOR({ dimension: 2048 }) + * ``` + * + * @category DataTypes + */ +export abstract class AbstractVECTORBase< + TOptions extends object, +> extends AbstractDataType { + readonly options!: TOptions; + + validate(value: unknown): asserts value is VectorValue { + if (this._getVectorIterable(value)) { + return; + } + + ValidationErrorItem.throwDataTypeValidationError( + util.format('%O is not a valid vector', value), + ); + } + + protected _getVectorIterable(value: unknown): VectorValue | null { + if (Array.isArray(value) || isTypedArrayIterable(value)) { + return value; + } + + return null; + } + + protected _validateVectorElement(item: unknown): number { + if (typeof item !== 'number' || !Number.isFinite(item)) { + ValidationErrorItem.throwDataTypeValidationError( + util.format('%O is not a valid vector', item), + ); + } + + return item; + } + + protected _validateDimension(dimension: number, max?: number): number { + if (!Number.isInteger(dimension) || dimension <= 0) { + throw new TypeError(`Invalid VECTOR dimension: ${dimension}`); + } + + if (max !== undefined && dimension > max) { + throw new TypeError(`Invalid VECTOR dimension: ${dimension} (max ${max})`); + } + + return dimension; + } + + protected _checkOptionSupport(dialect: AbstractDialect) { + if (!dialect.supports.dataTypes.VECTOR) { + throwUnsupportedDataType(dialect, 'VECTOR'); + } + } + + protected abstract _getTypeName(): string; + + protected _getSqlOptionParts(): string[] { + return []; + } + + toSql(): string { + const parts = this._getSqlOptionParts(); + + return parts.length ? `${this._getTypeName()}(${parts.join(', ')})` : this._getTypeName(); + } +} + +/** + * Generic VECTOR implementation used by dialects whose options fit {@link VectorOptions}. + * Dialects with stricter option models can extend {@link AbstractVECTORBase} directly. + */ +export class VECTOR extends AbstractVECTORBase { + /** @hidden */ + static readonly [DataTypeIdentifier]: string = 'VECTOR'; + + readonly options: VectorOptions; + + constructor(); + constructor(dimension: number, format?: string); + constructor(options: VectorOptions); + /** @hidden */ + constructor( + ...args: + | [] + | [dimension: number] + | [dimension: number, format: string] + | [options: VectorOptions] + ); + constructor(dimensionOrOptions?: number | VectorOptions, format?: string) { + super(); + + if (typeof dimensionOrOptions === 'object' && dimensionOrOptions !== null) { + this.options = { + ...(dimensionOrOptions.dimension !== undefined + ? { dimension: this._validateDimension(dimensionOrOptions.dimension) } + : {}), + ...(dimensionOrOptions.format !== undefined + ? { format: this._validateFormat(dimensionOrOptions.format) } + : {}), + }; + + return; + } + + this.options = { + ...(dimensionOrOptions !== undefined + ? { dimension: this._validateDimension(dimensionOrOptions) } + : {}), + ...(format !== undefined ? { format: this._validateFormat(format) } : {}), + }; + } + + protected _getTypeName(): string { + return 'VECTOR'; + } + + protected _validateFormat(format: string): string { + const normalized = format.trim().toLowerCase(); + + switch (normalized) { + case 'float': + case 'float32': + case 'float64': + case 'int': + case 'int8': + case 'int16': + case 'int32': + case 'binary': + return normalized; + default: + throw new TypeError(`Invalid VECTOR format: ${format}`); + } + } + + protected override _getSqlOptionParts(): string[] { + return [ + ...(this.options.dimension !== undefined ? [String(this.options.dimension)] : []), + ...(this.options.format !== undefined ? [this.options.format.toUpperCase()] : []), + ]; + } +} + +/** + * Returns true when the input is one of Sequelize's accepted numeric typed arrays. + * + * @param value + */ +function isTypedArrayIterable(value: unknown): value is NumericTypedArray { + return ( + value instanceof Int8Array || + value instanceof Uint8Array || + value instanceof Uint8ClampedArray || + value instanceof Int16Array || + value instanceof Uint16Array || + value instanceof Int32Array || + value instanceof Uint32Array || + value instanceof Float32Array || + value instanceof Float64Array + ); +} + function rejectBlobs(value: unknown) { // We have a DataType called BLOB. People might try to use the built-in Blob type with it, which they cannot. // To clarify why it doesn't work, we have a dedicated message for it. diff --git a/packages/core/src/abstract-dialect/dialect.ts b/packages/core/src/abstract-dialect/dialect.ts index f07bff2f512b..863389fc5ab4 100644 --- a/packages/core/src/abstract-dialect/dialect.ts +++ b/packages/core/src/abstract-dialect/dialect.ts @@ -200,6 +200,7 @@ export type DialectSupports = { GEOGRAPHY: boolean; HSTORE: boolean; TSVECTOR: boolean; + VECTOR: boolean; CIDR: boolean; INET: boolean; MACADDR: boolean; @@ -461,6 +462,7 @@ export abstract class AbstractDialect< GEOGRAPHY: false, HSTORE: false, TSVECTOR: false, + VECTOR: false, DATETIME: { infinity: false, }, diff --git a/packages/core/src/abstract-dialect/query-interface.d.ts b/packages/core/src/abstract-dialect/query-interface.d.ts index c81c6e420119..b4c5eea985ff 100644 --- a/packages/core/src/abstract-dialect/query-interface.d.ts +++ b/packages/core/src/abstract-dialect/query-interface.d.ts @@ -76,7 +76,7 @@ export interface TableNameWithSchema { export type TableName = string | TableNameWithSchema; -export type IndexType = AllowLowercase<'UNIQUE' | 'FULLTEXT' | 'SPATIAL'>; +export type IndexType = AllowLowercase<'UNIQUE' | 'FULLTEXT' | 'SPATIAL' | 'VECTOR'>; export type IndexMethod = 'BTREE' | 'HASH' | 'GIST' | 'SPGIST' | 'GIN' | 'BRIN' | string; export interface IndexField { @@ -116,7 +116,7 @@ export interface IndexOptions { parser?: string | null; /** - * Index type. Only used by mysql. One of `UNIQUE`, `FULLTEXT` and `SPATIAL` + * Index type. Used by MySQL (`UNIQUE`, `FULLTEXT`, `SPATIAL`) and Oracle (`VECTOR`). */ type?: IndexType | undefined; diff --git a/packages/core/src/data-types.ts b/packages/core/src/data-types.ts index 89b9f22f4754..62fa694c8461 100644 --- a/packages/core/src/data-types.ts +++ b/packages/core/src/data-types.ts @@ -88,3 +88,5 @@ export const MACADDR8 = classToInvokable(DataTypes.MACADDR8); export const CITEXT = classToInvokable(DataTypes.CITEXT); /** This is a simple wrapper to make the DataType constructable without `new`. See the return type for all available options. */ export const TSVECTOR = classToInvokable(DataTypes.TSVECTOR); +/** This is a simple wrapper to make the DataType constructable without `new`. See the return type for all available options. */ +export const VECTOR = classToInvokable(DataTypes.VECTOR); diff --git a/packages/core/src/sequelize.d.ts b/packages/core/src/sequelize.d.ts index 7b496e074611..b6cab01f490b 100644 --- a/packages/core/src/sequelize.d.ts +++ b/packages/core/src/sequelize.d.ts @@ -1,6 +1,6 @@ import type { Options as RetryAsPromisedOptions } from 'retry-as-promised'; import type { DataTypes, Op, Options, QueryTypes } from '.'; -import type { DataType } from './abstract-dialect/data-types.js'; +import type { DataType, VectorValue } from './abstract-dialect/data-types.js'; import type { AbstractDialect, ConnectionOptions } from './abstract-dialect/dialect.js'; import type { ColumnsDescription, @@ -611,6 +611,56 @@ export class Sequelize< */ random(): Fn; + /** + * Build a dialect-specific cosine distance function. + * Dialects that advertise `DialectSupportMap.dataTypes.VECTOR` translate this generic helper + * into their native similarity operator (Oracle, Snowflake samples, pgvector, ...). + * + * @param column column name or identifier to compare against + * @param value reference vector as a plain numeric array or typed array + */ + cosineDistance(column: string, value: VectorValue): Fn; + + /** + * Build a dialect-specific inner product function. + * Dialects that advertise `DialectSupportMap.dataTypes.VECTOR` translate this generic helper + * into their native similarity operator (Oracle, Snowflake samples, pgvector, ...). + * + * @param column column name or identifier to compare against + * @param value reference vector as a plain numeric array or typed array + */ + innerProduct(column: string, value: VectorValue): Fn; + + /** + * Build a dialect-specific L1 distance function. + * Dialects that advertise `DialectSupportMap.dataTypes.VECTOR` translate this generic helper + * into their native similarity operator (Oracle, Snowflake samples, pgvector, ...). + * + * @param column column name or identifier to compare against + * @param value reference vector as a plain numeric array or typed array + */ + l1Distance(column: string, value: VectorValue): Fn; + + /** + * Build a dialect-specific L2 distance function. + * Dialects that advertise `DialectSupportMap.dataTypes.VECTOR` translate this generic helper + * into their native similarity operator (Oracle, Snowflake samples, pgvector, ...). + * + * @param column column name or identifier to compare against + * @param value reference vector as a plain numeric array or typed array + */ + l2Distance(column: string, value: VectorValue): Fn; + + /** + * Build a dialect-specific default vector distance function. + * Dialects that advertise `DialectSupportMap.dataTypes.VECTOR` translate this generic helper + * into their native similarity operator (Oracle, Snowflake samples, pgvector, ...). + * + * @param column column name or identifier to compare against + * @param value reference vector as a plain numeric array or typed array + */ + vectorDistance(column: string, value: VectorValue): Fn; + /** * Execute a query which would set an environment or user variable. The variables are set per connection, * so this function needs a transaction. diff --git a/packages/core/src/sequelize.js b/packages/core/src/sequelize.js index 763ad7383908..0798d0a74d19 100644 --- a/packages/core/src/sequelize.js +++ b/packages/core/src/sequelize.js @@ -623,6 +623,38 @@ Use Sequelize#query if you wish to use replacements.`); return sql.random; } + cosineDistance(column, value) { + return this.#vectorSimilarityFn('COSINE_DISTANCE', column, value); + } + + innerProduct(column, value) { + return this.#vectorSimilarityFn('INNER_PRODUCT', column, value); + } + + l1Distance(column, value) { + return this.#vectorSimilarityFn('L1_DISTANCE', column, value); + } + + l2Distance(column, value) { + return this.#vectorSimilarityFn('L2_DISTANCE', column, value); + } + + vectorDistance(column, value) { + return this.#vectorSimilarityFn('VECTOR_DISTANCE', column, value); + } + + #vectorSimilarityFn(fnName, column, value) { + // Dialects that advertise VECTOR support can translate these generic helper names in their + // query generator. Oracle is the primary implementation; other dialects may follow this pattern. + if (!this.dialect.supports?.dataTypes?.VECTOR) { + throw new Error( + `${fnName.toLowerCase()} for dialect "${this.dialect.name}" is not implemented`, + ); + } + + return fn(fnName, column, value); + } + // Global exports static Fn = Fn; static Col = Col; diff --git a/packages/core/test/integration/dialects/oracle/vector.test.js b/packages/core/test/integration/dialects/oracle/vector.test.js new file mode 100644 index 000000000000..4369c7dbebda --- /dev/null +++ b/packages/core/test/integration/dialects/oracle/vector.test.js @@ -0,0 +1,411 @@ +'use strict'; + +const { DataTypes, Op, QueryTypes, sql } = require('@sequelize/core'); +const { expect } = require('chai'); +const semver = require('semver'); +const { getTestDialect, sequelize } = require('../../../support'); + +if (getTestDialect() === 'oracle') { + describe('[Oracle Specific] vectors', () => { + before(async function () { + const rawVersion = await sequelize.fetchDatabaseVersion(); + const normalized = semver.coerce(rawVersion)?.version; // e.g. 23.26.0 + + if (!normalized || !semver.gte(normalized, '23.4.0')) { + this.skip(); + } + }); + + const seedModel = async (model, rows) => { + await model.sync({ force: true }); + + for (const row of rows) { + await model.create(row); + } + }; + + describe('findAll', () => { + beforeEach(async function () { + this.Item = sequelize.define('Item', { + embeddings: DataTypes.VECTOR(4), + }); + + await seedModel(this.Item, [ + { embeddings: new Float32Array([1, 1, 1, 1]) }, + { embeddings: new Float32Array([1, 2, 3, 3]) }, + ]); + }); + + it('fetches rows', async function () { + const result = await this.Item.findAll(); + expect(result).to.have.length(2); + }); + + it('returns typed arrays for vector column', async function () { + const result = await this.Item.findAll(); + expect(result[0].getDataValue('embeddings').BYTES_PER_ELEMENT).to.equal(4); + }); + }); + + describe('similarity search functions', () => { + beforeEach(async function () { + this.Item = sequelize.define('Item', { + embeddings: DataTypes.VECTOR(3), + }); + + await seedModel(this.Item, [ + { embeddings: new Float32Array([1, 1, 1]) }, + { embeddings: new Float32Array([5, 5, 5]) }, + { embeddings: new Float32Array([10, 10, 10]) }, + { embeddings: new Float32Array([1, 2, 3]) }, + ]); + }); + + it('supports cosine distance filtering', async function () { + const queryVector = [1, 2, 3]; + const result = await this.Item.findAll({ + where: sql.where( + sql.fn('COSINE_DISTANCE', sql.attribute('embeddings'), queryVector), + Op.lt, + 0.01, + ), + }); + + expect(result.length).to.equal(1); + }); + + it('supports inner product filtering', async function () { + const queryVector = [1, 2, 3]; + const result = await this.Item.findAll({ + where: sql.where( + sql.fn('INNER_PRODUCT', sql.attribute('embeddings'), queryVector), + Op.gt, + 20, + ), + }); + + expect(result.length).to.equal(2); + }); + + it('supports l1 distance filtering', async function () { + const queryVector = [1, 2, 3]; + const result = await this.Item.findAll({ + where: sql.where( + sql.fn('L1_DISTANCE', sql.attribute('embeddings'), queryVector), + Op.lt, + 10, + ), + }); + + expect(result.length).to.equal(3); + }); + + it('supports l2 distance filtering', async function () { + const queryVector = [1, 2, 3]; + const result = await this.Item.findAll({ + where: sql.where( + sql.fn('L2_DISTANCE', sql.attribute('embeddings'), queryVector), + Op.lt, + 3, + ), + }); + + expect(result.length).to.equal(2); + }); + + it('supports all helper methods in where filters', async function () { + const queryVector = [1, 2, 3]; + const helperCases = [ + { name: 'cosineDistance', operator: Op.lt, threshold: 0.01, expected: 1 }, + { name: 'innerProduct', operator: Op.gt, threshold: 10, expected: 3 }, + { name: 'l1Distance', operator: Op.lt, threshold: 10, expected: 3 }, + { name: 'l2Distance', operator: Op.lt, threshold: 6, expected: 3 }, + { name: 'vectorDistance', operator: Op.lt, threshold: 0.01, expected: 1 }, + ]; + + for (const helperCase of helperCases) { + const result = await this.Item.findAll({ + where: sql.where( + sequelize[helperCase.name]('embeddings', queryVector), + helperCase.operator, + helperCase.threshold, + ), + }); + + expect(result.length, helperCase.name).to.equal(helperCase.expected); + } + }); + + it('supports raw SQL bind parameters for VECTOR_DISTANCE query vectors', async function () { + const tableName = sequelize.queryGenerator.quoteTable(this.Item); + const rows = await sequelize.query( + `SELECT "id" FROM ${tableName} WHERE VECTOR_DISTANCE("embeddings", $queryVector) < $threshold ORDER BY "id"`, + { + bind: { + queryVector: Float32Array.from([1, 2, 3]), + threshold: 0.05, + }, + type: QueryTypes.SELECT, + }, + ); + + expect(rows).to.have.length(1); + expect(rows[0].id).to.equal(4); + }); + + it('accepts valid VECTOR literal strings in vector functions', async function () { + const result = await this.Item.findAll({ + where: sql.where( + sql.fn('VECTOR_DISTANCE', sql.attribute('embeddings'), `VECTOR('[1,2,3]')`), + Op.lt, + 2, + ), + }); + + expect(result.length).to.equal(4); + }); + + it('rejects malformed VECTOR literal strings in vector functions', async function () { + await expect( + this.Item.findAll({ + where: sql.where( + sql.fn('VECTOR_DISTANCE', sql.attribute('embeddings'), `VECTR('[1,2,3]')`), + Op.lt, + 2, + ), + }), + ).to.be.rejected; + }); + + it('rejects non-finite vector elements in vector functions', async function () { + await expect( + this.Item.findAll({ + where: sql.where( + sql.fn('VECTOR_DISTANCE', sql.attribute('embeddings'), [1, Infinity, 3]), + Op.lt, + 2, + ), + }), + ).to.be.rejected; + }); + + it('rejects unsupported integer typed arrays as the query vector argument', async function () { + await expect( + this.Item.findAll({ + where: sql.where( + sql.fn('VECTOR_DISTANCE', sql.attribute('embeddings'), new Int8Array([1, 2, 3])), + Op.lt, + 2, + ), + }), + ).to.be.rejected; + }); + + it('supports all vector helper methods in ORDER BY', async function () { + const queryVector = [1, 2, 3]; + const helpers = [ + 'cosineDistance', + 'innerProduct', + 'l1Distance', + 'l2Distance', + 'vectorDistance', + ]; + + for (const helper of helpers) { + const result = await this.Item.findAll({ + order: [sequelize[helper]('embeddings', queryVector)], + limit: 1, + }); + + expect(result).to.have.length(1); + } + }); + }); + + describe('vector input validation and persistence', () => { + beforeEach(async function () { + this.Item = sequelize.define('VectorInputItem', { + embeddings: DataTypes.VECTOR(3), + }); + + await this.Item.sync({ force: true }); + }); + + for (const [name, value] of [ + ['number array', [1, 2, 3]], + ['Float32Array', new Float32Array([1, 2, 3])], + ['Float64Array', new Float64Array([1, 2, 3])], + ['Int8Array', new Int8Array([1, 2, 3])], + ]) { + it(`accepts ${name} input`, async function () { + await this.Item.create({ embeddings: value }); + + const row = await this.Item.findOne(); + expect(Array.from(row.getDataValue('embeddings'))).to.deep.equal([1, 2, 3]); + }); + } + + it('persists updates performed with a plain number array', async function () { + const item = await this.Item.create({ embeddings: new Float32Array([1, 2, 3]) }); + + await item.update({ embeddings: [4, 5, 6] }); + + await item.reload(); + expect(Array.from(item.getDataValue('embeddings'))).to.deep.equal([4, 5, 6]); + }); + + for (const [name, value, error] of [ + ['DataView', new DataView(new ArrayBuffer(3)), 'is not a valid vector'], + ['string', '1,2,3', 'is not a valid vector'], + ['non-number array', [1, 'a', 3], 'ORA-51805'], + ]) { + it(`rejects ${name} input`, async function () { + await expect(this.Item.create({ embeddings: value })).to.be.rejectedWith(Error, error); + }); + } + + it('stores Uint8Array in binary vectors with matching bit-dimension', async () => { + const BinaryItem = sequelize.define('BinaryVectorInputItem', { + embeddings: DataTypes.VECTOR(24, 'binary'), + }); + + await BinaryItem.sync({ force: true }); + await BinaryItem.create({ embeddings: new Uint8Array([1, 2, 3]) }); + + const row = await BinaryItem.findOne(); + expect(row).to.not.equal(null); + }); + + it('accepts object-style binary format options', async () => { + const BinaryItem = sequelize.define('BinaryVectorInputItemObjectStyle', { + embeddings: DataTypes.VECTOR({ dimension: 24, format: 'binary' }), + }); + + await BinaryItem.sync({ force: true }); + await BinaryItem.create({ embeddings: new Uint8Array([1, 2, 3]) }); + + const row = await BinaryItem.findOne(); + expect(row).to.not.equal(null); + }); + + it('accepts uppercase binary format in constructor arguments', async () => { + const BinaryItem = sequelize.define('BinaryVectorInputItemUppercase', { + embeddings: DataTypes.VECTOR(24, 'BINARY'), + }); + + await BinaryItem.sync({ force: true }); + await BinaryItem.create({ embeddings: new Uint8Array([1, 2, 3]) }); + + const row = await BinaryItem.findOne(); + expect(row).to.not.equal(null); + }); + }); + + describe('vector indexes', () => { + it('creates vector index from model indexes option during sync', async () => { + const indexName = 'vector_input_item_embeddings_hnsw_idx'; + const IndexedItem = sequelize.define( + 'VectorIndexedItem', + { + embeddings: DataTypes.VECTOR(3), + }, + { + indexes: [ + { + name: indexName, + type: 'VECTOR', + fields: ['embeddings'], + using: 'hnsw', + parameter: { neighbor: 8, efconstruction: 32 }, + }, + ], + }, + ); + + try { + await IndexedItem.sync({ force: true }); + + const indexes = await sequelize.queryInterface.showIndex(IndexedItem.table); + const vectorIndex = indexes.find( + index => index.name?.toLowerCase() === indexName.toLowerCase(), + ); + + expect(vectorIndex).to.not.equal(undefined); + expect(vectorIndex.type).to.equal('VECTOR'); + } finally { + try { + await sequelize.queryInterface.removeIndex(IndexedItem.table, indexName); + } finally { + await IndexedItem.drop(); + } + } + }); + + it('creates vector index with lowercase distance metric during sync', async () => { + const indexName = 'vector_input_item_embeddings_cosine_idx'; + const IndexedItem = sequelize.define( + 'VectorDistanceIndexedItem', + { + embeddings: DataTypes.VECTOR(3), + }, + { + indexes: [ + { + name: indexName, + type: 'VECTOR', + fields: ['embeddings'], + using: 'hnsw', + distance: 'cosine', + }, + ], + }, + ); + + try { + await IndexedItem.sync({ force: true }); + + const indexes = await sequelize.queryInterface.showIndex(IndexedItem.table); + const vectorIndex = indexes.find( + index => index.name?.toLowerCase() === indexName.toLowerCase(), + ); + + expect(vectorIndex).to.not.equal(undefined); + expect(vectorIndex.type).to.equal('VECTOR'); + } finally { + try { + await sequelize.queryInterface.removeIndex(IndexedItem.table, indexName); + } finally { + await IndexedItem.drop(); + } + } + }); + + it('rejects ordered vector index fields during sync', async () => { + const IndexedItem = sequelize.define( + 'VectorOrderedIndexedItem', + { + embeddings: DataTypes.VECTOR(3), + }, + { + indexes: [ + { + name: 'vector_input_item_embeddings_desc_idx', + type: 'VECTOR', + fields: [{ name: 'embeddings', order: 'desc' }], + using: 'hnsw', + }, + ], + }, + ); + + try { + await expect(IndexedItem.sync({ force: true })).to.be.rejectedWith( + 'Oracle VECTOR indexes do not support ordered fields.', + ); + } finally { + await IndexedItem.drop().catch(() => {}); + } + }); + }); + }); +} diff --git a/packages/core/test/unit/data-types/string-types.test.ts b/packages/core/test/unit/data-types/string-types.test.ts index 5279749b8fe1..6b55096fe8e7 100644 --- a/packages/core/test/unit/data-types/string-types.test.ts +++ b/packages/core/test/unit/data-types/string-types.test.ts @@ -214,3 +214,126 @@ See https://sequelize.org/docs/v7/models/data-types/ for a list of supported dat }); }); }); + +describe('DataTypes.VECTOR', () => { + describe('constructor', () => { + it('stores empty options when no args are passed', () => { + const type = DataTypes.VECTOR(); + + expect(type.options).to.deep.equal({}); + }); + + it('stores dimension when only dimension is passed', () => { + const type = DataTypes.VECTOR(4); + + expect(type.options).to.deep.equal({ dimension: 4 }); + }); + + it('stores dimension and format when both args are passed', () => { + const type = DataTypes.VECTOR(3, 'float32'); + + expect(type.options).to.deep.equal({ dimension: 3, format: 'float32' }); + }); + + it('supports object-style options', () => { + const type = DataTypes.VECTOR({ dimension: 8, format: 'float64' }); + + expect(type.options).to.deep.equal({ dimension: 8, format: 'float64' }); + }); + + it('supports object-style options with only dimension', () => { + const type = DataTypes.VECTOR({ dimension: 8 }); + + expect(type.options).to.deep.equal({ dimension: 8 }); + }); + + it('normalizes binary format casing in constructor options', () => { + const type = DataTypes.VECTOR(24, 'BINARY'); + + expect(type.options).to.deep.equal({ dimension: 24, format: 'binary' }); + }); + + it('rejects invalid dimensions', () => { + expect(() => DataTypes.VECTOR(0)).to.throw(TypeError, 'Invalid VECTOR dimension'); + }); + + it('rejects invalid object-style dimensions', () => { + expect(() => DataTypes.VECTOR({ dimension: 0 })).to.throw( + TypeError, + 'Invalid VECTOR dimension', + ); + expect(() => DataTypes.VECTOR({ dimension: 1.5 })).to.throw( + TypeError, + 'Invalid VECTOR dimension', + ); + }); + + it('rejects unknown formats', () => { + expect(() => DataTypes.VECTOR(3, 'float32) DROP TABLE x; --')).to.throw( + TypeError, + 'Invalid VECTOR format', + ); + }); + + it('rejects unknown object-style formats', () => { + expect(() => DataTypes.VECTOR({ dimension: 3, format: 'drop table x' })).to.throw( + TypeError, + 'Invalid VECTOR format', + ); + }); + }); + + describe('toSql', () => { + testDataTypeSql('VECTOR', DataTypes.VECTOR, { + default: new Error(`${dialectName} does not support the VECTOR data type. +See https://sequelize.org/docs/v7/models/data-types/ for a list of supported data types.`), + oracle: 'VECTOR', + }); + + testDataTypeSql('VECTOR(4)', DataTypes.VECTOR(4), { + default: new Error(`${dialectName} does not support the VECTOR data type. +See https://sequelize.org/docs/v7/models/data-types/ for a list of supported data types.`), + oracle: 'VECTOR(4)', + }); + + testDataTypeSql("VECTOR(3, 'float32')", DataTypes.VECTOR(3, 'float32'), { + default: new Error(`${dialectName} does not support the VECTOR data type. +See https://sequelize.org/docs/v7/models/data-types/ for a list of supported data types.`), + oracle: 'VECTOR(3, FLOAT32)', + }); + + testDataTypeSql("VECTOR(24, 'binary')", DataTypes.VECTOR(24, 'binary'), { + default: new Error(`${dialectName} does not support the VECTOR data type. +See https://sequelize.org/docs/v7/models/data-types/ for a list of supported data types.`), + oracle: 'VECTOR(24, BINARY)', + }); + }); + + describe('validate', () => { + it('should throw an error if value is invalid', () => { + const type: DataTypeInstance = DataTypes.VECTOR(); + + expect(() => { + type.validate('vector'); + }).to.throw(ValidationErrorItem, "'vector' is not a valid vector"); + }); + + it('should not throw if value is an array', () => { + const type: DataTypeInstance = DataTypes.VECTOR(); + + expect(() => type.validate([1, 2, 3])).not.to.throw(); + }); + + it('should not throw if value is a typed array', () => { + const type: DataTypeInstance = DataTypes.VECTOR(); + + expect(() => type.validate(new Float32Array([1, 2, 3]))).not.to.throw(); + }); + + it('should not validate vector elements in the base type', () => { + const type: DataTypeInstance = DataTypes.VECTOR(); + + expect(() => type.validate([1, Infinity, 3])).not.to.throw(); + }); + }); +}); diff --git a/packages/core/test/unit/dialects/oracle/query-generator.test.js b/packages/core/test/unit/dialects/oracle/query-generator.test.js index ff6973519049..94c1e48fe909 100644 --- a/packages/core/test/unit/dialects/oracle/query-generator.test.js +++ b/packages/core/test/unit/dialects/oracle/query-generator.test.js @@ -17,6 +17,7 @@ if (dialect.startsWith('oracle')) { const sequelize = Support.createSequelizeInstance(); const dialect = sequelize.dialect; const integerDialect = new DataTypes.INTEGER().toDialectDataType(dialect); + const vectorDialect = DataTypes.VECTOR(3).toDialectDataType(dialect); const suites = { attributesToSQL: [ @@ -408,6 +409,13 @@ if (dialect.startsWith('oracle')) { }, needsSequelize: true, }, + { + arguments: ['myTable', { embedding: [1, 2, 3] }, { embedding: { type: vectorDialect } }], + expectation: { + query: 'INSERT INTO "myTable" ("embedding") VALUES ($sequelize_1);', + bind: { sequelize_1: Float64Array.from([1, 2, 3]) }, + }, + }, // Variants when quoteIdentifiers is false { @@ -641,6 +649,19 @@ if (dialect.startsWith('oracle')) { }, needsSequelize: true, }, + { + arguments: [ + 'myTable', + { embedding: [4, 5, 6] }, + { id: 1 }, + {}, + { embedding: { type: vectorDialect } }, + ], + expectation: { + query: 'UPDATE "myTable" SET "embedding"=$sequelize_1 WHERE "id" = $sequelize_2', + bind: { sequelize_1: Float64Array.from([4, 5, 6]), sequelize_2: 1 }, + }, + }, { arguments: [ 'myTable', diff --git a/packages/core/test/unit/dialects/oracle/vector.test.js b/packages/core/test/unit/dialects/oracle/vector.test.js new file mode 100644 index 000000000000..f2a87b0aa53c --- /dev/null +++ b/packages/core/test/unit/dialects/oracle/vector.test.js @@ -0,0 +1,713 @@ +'use strict'; + +const util = require('node:util'); +const Support = require('../../../support'); +const { DataTypes, Op, sql } = require('@sequelize/core'); +const BaseTypes = require('@sequelize/core/_non-semver-use-at-your-own-risk_/abstract-dialect/data-types.js'); +const { + buildInvalidOptionReceivedError, +} = require('@sequelize/core/_non-semver-use-at-your-own-risk_/utils/check.js'); +const { OracleQuery } = require('@sequelize/oracle'); +const { expect } = require('chai'); + +const expectsql = Support.expectsql; +const current = Support.sequelize; +const queryGenerator = current.dialect.queryGenerator; + +if (current.dialect.name === 'oracle') { + describe('[Oracle Specific] VECTOR', () => { + describe('VECTOR datatype', () => { + it('creates table with vector datatype', () => { + const FooUser = current.define('user', { + embedding: { + type: DataTypes.VECTOR, + allowNull: false, + }, + }); + + expectsql( + queryGenerator.createTableQuery( + FooUser.table, + queryGenerator.attributesToSQL(FooUser.getAttributes()), + {}, + ), + { + default: `BEGIN EXECUTE IMMEDIATE 'CREATE TABLE "users" ("id" NUMBER(*,0) GENERATED BY DEFAULT ON NULL AS IDENTITY, "embedding" VECTOR NOT NULL, "createdAt" TIMESTAMP WITH LOCAL TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH LOCAL TIME ZONE NOT NULL,PRIMARY KEY ("id"))'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -955 THEN RAISE; END IF; END;`, + }, + ); + }); + }); + + describe('VECTOR datatype with dimension and format', () => { + it('creates table with vector datatype', () => { + const FooUser = current.define('user', { + embedding: { + type: DataTypes.VECTOR(3, 'float32'), + allowNull: false, + }, + }); + + expectsql( + queryGenerator.createTableQuery( + FooUser.table, + queryGenerator.attributesToSQL(FooUser.getAttributes()), + {}, + ), + { + default: `BEGIN EXECUTE IMMEDIATE 'CREATE TABLE "users" ("id" NUMBER(*,0) GENERATED BY DEFAULT ON NULL AS IDENTITY, "embedding" VECTOR(3, FLOAT32) NOT NULL, "createdAt" TIMESTAMP WITH LOCAL TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH LOCAL TIME ZONE NOT NULL,PRIMARY KEY ("id"))'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -955 THEN RAISE; END IF; END;`, + }, + ); + }); + }); + + describe('Vector index', () => { + it('default', () => { + expectsql(queryGenerator.addIndexQuery('Foo', ['vec1'], { type: 'VECTOR' }), { + default: + 'CREATE VECTOR INDEX "foo_vec1" ON "Foo" ("vec1") ORGANIZATION INMEMORY NEIGHBOR GRAPH', + }); + }); + + it('defaults using to hnsw when not provided', () => { + expectsql( + queryGenerator.addIndexQuery('foo', { + fields: ['vec1'], + type: 'VECTOR', + }), + { + default: + 'CREATE VECTOR INDEX "foo_vec1" ON "foo" ("vec1") ORGANIZATION INMEMORY NEIGHBOR GRAPH', + }, + ); + }); + + it('sanitizes dotted and quoted prefixes when generating vector index names', () => { + expectsql( + queryGenerator.addIndexQuery('ignored', ['vec1'], { + type: 'VECTOR', + prefix: '"schema"."foo"', + }), + { + default: + 'CREATE VECTOR INDEX "schema_foo_vec1" ON "ignored" ("vec1") ORGANIZATION INMEMORY NEIGHBOR GRAPH', + }, + ); + }); + + it('accepts lowercase vector type', () => { + expectsql(queryGenerator.addIndexQuery('Foo', ['vec1'], { type: 'vector' }), { + default: + 'CREATE VECTOR INDEX "foo_vec1" ON "Foo" ("vec1") ORGANIZATION INMEMORY NEIGHBOR GRAPH', + }); + }); + + it('accepts object-form index definition with lowercase vector type', () => { + expectsql( + queryGenerator.addIndexQuery('Foo', { + fields: ['vec1'], + type: 'vector', + using: 'ivf', + }), + { + default: + 'CREATE VECTOR INDEX "foo_vec1" ON "Foo" ("vec1") ORGANIZATION NEIGHBOR PARTITIONS', + }, + ); + }); + + it('ivf with parameters and target accuracy', () => { + expectsql( + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + using: 'ivf', + accuracy: 95, + parameter: { partitions: 5, samplesPerPartition: 10, minVectors: 10 }, + }), + { + default: + 'CREATE VECTOR INDEX "foo_vec1" ON "foo" ("vec1") ORGANIZATION NEIGHBOR PARTITIONS WITH TARGET ACCURACY 95 PARAMETERS (type ivf, NEIGHBOR PARTITIONS 5, SAMPLES_PER_PARTITION 10, MIN_VECTORS_PER_PARTITION 10)', + }, + ); + }); + + it('supports explicit vector distance metric', () => { + expectsql( + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + distance: 'COSINE', + }), + { + default: + 'CREATE VECTOR INDEX "foo_vec1" ON "foo" ("vec1") ORGANIZATION INMEMORY NEIGHBOR GRAPH WITH DISTANCE COSINE', + }, + ); + }); + + it('normalizes lowercase vector distance metric', () => { + expectsql( + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + distance: 'cosine', + }), + { + default: + 'CREATE VECTOR INDEX "foo_vec1" ON "foo" ("vec1") ORGANIZATION INMEMORY NEIGHBOR GRAPH WITH DISTANCE COSINE', + }, + ); + }); + + it('rejects unsupported vector index using values', () => { + expect(() => + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + using: 'btree', + }), + ).to.throw(TypeError, 'Oracle VECTOR index using must be either "hnsw" or "ivf".'); + }); + + it('rejects non-string vector index using values', () => { + expect(() => + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + using: 123, + }), + ).to.throw(TypeError, 'Oracle VECTOR index using must be either "hnsw" or "ivf".'); + }); + + it('accepts uppercase using value', () => { + expectsql( + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + using: 'HNSW', + }), + { + default: + 'CREATE VECTOR INDEX "foo_vec1" ON "foo" ("vec1") ORGANIZATION INMEMORY NEIGHBOR GRAPH', + }, + ); + }); + + it('accepts uppercase ivf using value', () => { + expectsql( + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'vector', + using: 'IVF', + }), + { + default: + 'CREATE VECTOR INDEX "foo_vec1" ON "foo" ("vec1") ORGANIZATION NEIGHBOR PARTITIONS', + }, + ); + }); + + it('ignores ivf parameters when using hnsw', () => { + expectsql( + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + using: 'hnsw', + parameter: { partitions: 4 }, + }), + { + default: + 'CREATE VECTOR INDEX "foo_vec1" ON "foo" ("vec1") ORGANIZATION INMEMORY NEIGHBOR GRAPH PARAMETERS (type hnsw)', + }, + ); + }); + + it('ignores hnsw parameters when using ivf', () => { + expectsql( + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + using: 'ivf', + parameter: { neighbor: 8 }, + }), + { + default: + 'CREATE VECTOR INDEX "foo_vec1" ON "foo" ("vec1") ORGANIZATION NEIGHBOR PARTITIONS PARAMETERS (type ivf)', + }, + ); + }); + + it('supports hnsw parameters in SQL', () => { + expectsql( + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + using: 'hnsw', + parameter: { neighbor: 8, efconstruction: 32 }, + }), + { + default: + 'CREATE VECTOR INDEX "foo_vec1" ON "foo" ("vec1") ORGANIZATION INMEMORY NEIGHBOR GRAPH PARAMETERS (type hnsw, neighbor 8, efconstruction 32)', + }, + ); + }); + + it('supports parallel degree in SQL', () => { + expectsql( + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + using: 'ivf', + parallel: 4, + }), + { + default: + 'CREATE VECTOR INDEX "foo_vec1" ON "foo" ("vec1") ORGANIZATION NEIGHBOR PARTITIONS PARALLEL 4', + }, + ); + }); + + it('rejects unsupported vector index options instead of ignoring them', () => { + expect(() => + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + unique: true, + }), + ).to.throw(buildInvalidOptionReceivedError('addIndexQuery', 'oracle', ['unique']).message); + }); + + it('rejects unsafe vector index distance fragments', () => { + expect(() => + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + distance: 'COSINE) PARAMETERS (type hnsw', + }), + ).to.throw(TypeError, 'Oracle VECTOR index distance must be one of'); + }); + + it('rejects unsafe vector index accuracy fragments', () => { + expect(() => + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + accuracy: '95) PARAMETERS (type hnsw', + }), + ).to.throw(TypeError, 'Oracle VECTOR index accuracy must be a positive number.'); + }); + + it('rejects unsafe vector index parameter fragments', () => { + expect(() => + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + parameter: { neighbor: '8) PARAMETERS (type ivf' }, + }), + ).to.throw(TypeError, 'Oracle VECTOR index parameter.neighbor must be a positive integer.'); + }); + + it('rejects unsafe vector index parallel fragments', () => { + expect(() => + queryGenerator.addIndexQuery('foo', ['vec1'], { + type: 'VECTOR', + parallel: '4 PARAMETERS (type hnsw)', + }), + ).to.throw(TypeError, 'Oracle VECTOR index parallel must be a positive integer.'); + }); + + it('rejects ordered vector index fields', () => { + expect(() => + queryGenerator.addIndexQuery('foo', { + fields: [{ name: 'vec1', order: 'DESC' }], + type: 'VECTOR', + }), + ).to.throw(TypeError, 'Oracle VECTOR indexes do not support ordered fields.'); + }); + + it('supports SQL expression index fields', () => { + expectsql( + queryGenerator.addIndexQuery('foo', { + name: 'foo_vec_expr', + fields: [sql.literal('LOWER("vec1")')], + type: 'VECTOR', + }), + { + default: + 'CREATE VECTOR INDEX "foo_vec_expr" ON "foo" (LOWER("vec1")) ORGANIZATION INMEMORY NEIGHBOR GRAPH', + }, + ); + }); + + it('throws when no fields are provided', () => { + expect(() => queryGenerator.addIndexQuery('foo', [], { type: 'VECTOR' })).to.throw( + Error, + 'Vector indexes require at least one indexed field.', + ); + }); + + it('throws when a vector index field has no name', () => { + expect(() => + queryGenerator.addIndexQuery('foo', { + fields: [{}], + type: 'VECTOR', + }), + ).to.throw(Error, 'The following index field has no name'); + }); + }); + + describe('showIndex metadata', () => { + const buildQuery = () => new OracleQuery({}, current, {}); + + it('reports vector method hnsw', () => { + const query = buildQuery(); + const rows = [ + { + INDEX_NAME: 'IDX_HNSW', + INDEX_TYPE: 'VECTOR', + INDEX_SUBTYPE: 'INMEMORY_NEIGHBOR_GRAPH_HNSW', + ITYP_NAME: null, + UNIQUENESS: 'NONUNIQUE', + CONSTRAINT_TYPE: null, + TABLE_NAME: 'FOO', + COLUMN_NAME: 'EMBEDDING', + DESCEND: null, + }, + ]; + + const result = query.handleShowIndexesQuery(rows); + expect(result[0].method).to.equal('hnsw'); + }); + + it('reports vector method ivf', () => { + const query = buildQuery(); + const rows = [ + { + INDEX_NAME: 'IDX_IVF', + INDEX_TYPE: 'VECTOR', + INDEX_SUBTYPE: 'NEIGHBOR_PARTITION_GRAPH_IVF', + ITYP_NAME: null, + UNIQUENESS: 'NONUNIQUE', + CONSTRAINT_TYPE: null, + TABLE_NAME: 'FOO', + COLUMN_NAME: 'EMBEDDING', + DESCEND: null, + }, + ]; + + const result = query.handleShowIndexesQuery(rows); + expect(result[0].method).to.equal('ivf'); + }); + + it('leaves method undefined when parameters missing', () => { + const query = buildQuery(); + const rows = [ + { + INDEX_NAME: 'IDX_UNKNOWN', + INDEX_TYPE: 'VECTOR', + ITYP_NAME: null, + UNIQUENESS: 'NONUNIQUE', + CONSTRAINT_TYPE: null, + TABLE_NAME: 'FOO', + COLUMN_NAME: 'EMBEDDING', + DESCEND: null, + }, + ]; + + const result = query.handleShowIndexesQuery(rows); + expect(result[0].method).to.equal(undefined); + }); + + it('maps NORMAL index type to undefined', () => { + const query = buildQuery(); + const rows = [ + { + INDEX_NAME: 'IDX_NORMAL', + INDEX_TYPE: 'NORMAL', + ITYP_NAME: null, + UNIQUENESS: 'NONUNIQUE', + CONSTRAINT_TYPE: null, + TABLE_NAME: 'FOO', + COLUMN_NAME: 'ID', + DESCEND: null, + }, + ]; + + const result = query.handleShowIndexesQuery(rows); + expect(result[0].type).to.equal(undefined); + }); + + it('keeps non-vector index types', () => { + const query = buildQuery(); + const rows = [ + { + INDEX_NAME: 'IDX_BITMAP', + INDEX_TYPE: 'BITMAP', + ITYP_NAME: null, + UNIQUENESS: 'NONUNIQUE', + CONSTRAINT_TYPE: null, + TABLE_NAME: 'FOO', + COLUMN_NAME: 'ID', + DESCEND: null, + }, + ]; + + const result = query.handleShowIndexesQuery(rows); + expect(result[0].type).to.equal('BITMAP'); + }); + }); + + describe('VECTOR datatype override hooks', () => { + it('converts array input to Float64Array bind values', () => { + const type = DataTypes.VECTOR(3).toDialectDataType(current.dialect); + const bindValue = type.toBindableValue([1, 2, 3]); + + expect(bindValue).to.be.instanceOf(Float64Array); + expect(Array.from(bindValue)).to.deep.equal([1, 2, 3]); + }); + + it('keeps typed array input as-is for binding', () => { + const type = DataTypes.VECTOR(3).toDialectDataType(current.dialect); + const typed = new Float32Array([1, 2, 3]); + + expect(type.toBindableValue(typed)).to.equal(typed); + }); + + it('accepts SparseVector-like input and keeps it as-is for binding', () => { + const type = DataTypes.VECTOR(5).toDialectDataType(current.dialect); + const sparseVector = { + values: new Float64Array([1, 2]), + indices: new Uint32Array([2, 4]), + numDimensions: 5, + }; + + expect(() => type.validate(sparseVector)).not.to.throw(); + expect(type.toBindableValue(sparseVector)).to.equal(sparseVector); + }); + + it('rejects invalid SparseVector-like input', () => { + const type = DataTypes.VECTOR(5).toDialectDataType(current.dialect); + + expect(() => + type.validate({ + values: new Float64Array([1, 2]), + indices: new Uint32Array([2]), + numDimensions: 5, + }), + ).to.throw(Error, 'is not a valid vector'); + }); + + it('returns the Oracle VECTOR bind definition', () => { + const type = DataTypes.VECTOR(3).toDialectDataType(current.dialect); + + expect(type._getBindDef({ DB_TYPE_VECTOR: 'DB_TYPE_VECTOR' })).to.deep.equal({ + type: 'DB_TYPE_VECTOR', + }); + }); + + it('rejects unsupported Oracle VECTOR formats', () => { + expect(() => DataTypes.VECTOR(3, 'int16').toDialectDataType(current.dialect)).to.throw( + TypeError, + 'Invalid Oracle VECTOR format: int16', + ); + }); + + it('accepts Oracle binary format and renders BINARY in SQL', () => { + const type = DataTypes.VECTOR(24, 'binary').toDialectDataType(current.dialect); + + expect(type.toSql()).to.equal('VECTOR(24, BINARY)'); + }); + + it('renders object-style binary options in SQL', () => { + const type = DataTypes.VECTOR({ dimension: 24, format: 'binary' }).toDialectDataType( + current.dialect, + ); + + expect(type.toSql()).to.equal('VECTOR(24, BINARY)'); + }); + + it('keeps object-style dimension-only SQL concise', () => { + const type = DataTypes.VECTOR({ dimension: 24 }).toDialectDataType(current.dialect); + + expect(type.toSql()).to.equal('VECTOR(24)'); + }); + + it('renders Oracle-specific sparse storage when using the Oracle VECTOR override directly', () => { + const OracleVector = current.dialect.getDataTypeForDialect(BaseTypes.VECTOR); + const type = new OracleVector({ dimension: 8, format: 'int8', storage: 'sparse' }); + + expect(type.toSql()).to.equal('VECTOR(8, INT8, SPARSE)'); + }); + + it('rejects unsupported Oracle VECTOR storage options', () => { + const OracleVector = current.dialect.getDataTypeForDialect(BaseTypes.VECTOR); + + expect(() => new OracleVector({ dimension: 8, storage: 'dense' })).to.throw( + TypeError, + 'Invalid Oracle VECTOR storage: dense', + ); + }); + }); + + describe('Vector where clause', () => { + const queryVector = [1, 2, 3]; + + const testsql = (key, value, options, expectation) => { + if (expectation === undefined) { + expectation = options; + options = undefined; + } + + const whereObj = sql.where(key, value); + it( + util.inspect(whereObj, { depth: 4 }) + (options ? `, ${util.inspect(options)}` : ''), + () => { + return expectsql(queryGenerator.whereItemsQuery(whereObj, options), expectation); + }, + ); + }; + + testsql( + sql.fn('VECTOR_DISTANCE', sql.attribute('embedding'), queryVector), + { + [Op.lt]: 2, + }, + { + oracle: `VECTOR_DISTANCE("embedding", VECTOR('[1,2,3]')) < 2`, + }, + ); + + testsql( + sql.fn('VECTOR_DISTANCE', sql.attribute('embedding'), new Float32Array([1, 2, 3])), + { + [Op.lt]: 2, + }, + { + oracle: `VECTOR_DISTANCE("embedding", VECTOR('[1,2,3]')) < 2`, + }, + ); + + testsql( + sql.fn('VECTOR_DISTANCE', sql.attribute('embedding'), `VECTOR('[1,2,3]')`), + { + [Op.lt]: 2, + }, + { + oracle: `VECTOR_DISTANCE("embedding", VECTOR('[1,2,3]')) < 2`, + }, + ); + + testsql( + sql.fn('VECTOR_DISTANCE', sql.attribute('embedding'), new Uint8Array([1, 2, 3])), + { + [Op.lt]: 2, + }, + { + oracle: `VECTOR_DISTANCE("embedding", VECTOR('[1,2,3]')) < 2`, + }, + ); + + it('throws when second argument is an unsupported integer typed array', () => { + expect(() => + queryGenerator.whereItemsQuery( + sql.where(sql.fn('VECTOR_DISTANCE', sql.attribute('embedding'), new Int8Array([1])), { + [Op.lt]: 2, + }), + ), + ).to.throw(Error, 'Invalid value received for the "where" option'); + }); + + it('throws when second argument is not a vector input', () => { + expect(() => + queryGenerator.whereItemsQuery( + sql.where(sql.fn('VECTOR_DISTANCE', sql.attribute('embedding'), 'not-a-vector'), { + [Op.lt]: 2, + }), + ), + ).to.throw(Error, 'Invalid value received for the "where" option'); + }); + + it('throws for non-finite vector array elements', () => { + expect(() => + queryGenerator.whereItemsQuery( + sql.where(sql.fn('VECTOR_DISTANCE', sql.attribute('embedding'), [1, Infinity, 3]), { + [Op.lt]: 2, + }), + ), + ).to.throw(Error, 'Invalid value received for the "where" option'); + }); + + it('throws for malformed VECTOR literal strings', () => { + expect(() => + queryGenerator.whereItemsQuery( + sql.where(sql.fn('VECTOR_DISTANCE', sql.attribute('embedding'), `VECTR('[1,2,3]')`), { + [Op.lt]: 2, + }), + ), + ).to.throw(Error, 'Invalid value received for the "where" option'); + }); + + it('throws for unsafe VECTOR literal strings', () => { + for (const payload of [ + `VECTOR('[1,2,3]'); DROP TABLE users; --')`, + `VECTOR('[1,2,3]') || 'x'`, + `VECTOR_DISTANCE("embedding", VECTOR('[1,2,3]'))`, + ]) { + expect(() => + queryGenerator.whereItemsQuery( + sql.where(sql.fn('VECTOR_DISTANCE', sql.attribute('embedding'), payload), { + [Op.lt]: 2, + }), + ), + ).to.throw(Error, 'Invalid value received for the "where" option'); + } + }); + + it('throws for malformed VECTOR literal payloads', () => { + expect(() => + queryGenerator.whereItemsQuery( + sql.where( + sql.fn('VECTOR_DISTANCE', sql.attribute('embedding'), `VECTOR('[1,foo,3]')`), + { + [Op.lt]: 2, + }, + ), + ), + ).to.throw(Error, 'Invalid value received for the "where" option'); + }); + + it('throws when vector function has too few arguments', () => { + expect(() => + queryGenerator.formatSqlExpression(sql.fn('VECTOR_DISTANCE', sql.attribute('embedding'))), + ).to.throw(Error, 'VECTOR_DISTANCE expects exactly 2 arguments'); + }); + + it('throws when vector function has too many arguments', () => { + expect(() => + queryGenerator.formatSqlExpression( + sql.fn('VECTOR_DISTANCE', sql.attribute('embedding'), [1, 2, 3], [4, 5, 6]), + ), + ).to.throw(Error, 'VECTOR_DISTANCE expects exactly 2 arguments'); + }); + }); + + describe('order by distance with limit', () => { + const queryVector = [1, 2, 3, 4]; + + const User = Support.sequelize.define( + 'User', + { + embedding: { + type: DataTypes.VECTOR(4), + }, + }, + { + tableName: 'user', + }, + ); + + it('generates order by vector distance', () => { + expectsql( + queryGenerator.selectQuery( + User.table, + { + model: User, + attributes: ['embedding'], + order: [sql.fn('VECTOR_DISTANCE', sql.attribute('embedding'), queryVector)], + limit: 5, + }, + User, + ), + { + oracle: `SELECT "embedding" FROM "user" "User" ORDER BY VECTOR_DISTANCE("embedding", VECTOR('[1,2,3,4]')) OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY;`, + }, + ); + }); + }); + }); +} diff --git a/packages/core/test/unit/query-generator/show-indexes-query.test.ts b/packages/core/test/unit/query-generator/show-indexes-query.test.ts index d85318b1f61d..fec3b7bbd429 100644 --- a/packages/core/test/unit/query-generator/show-indexes-query.test.ts +++ b/packages/core/test/unit/query-generator/show-indexes-query.test.ts @@ -29,7 +29,25 @@ describe('QueryGenerator#showIndexesQuery', () => { QSYS2.SYSKEYS.COLUMN_NAME, CAST('INDEX' AS VARCHAR(11)), QSYS2.SYSINDEXES.TABLE_SCHEMA, QSYS2.SYSINDEXES.TABLE_NAME from QSYS2.SYSKEYS left outer join QSYS2.SYSINDEXES on QSYS2.SYSKEYS.INDEX_NAME = QSYS2.SYSINDEXES.INDEX_NAME where QSYS2.SYSINDEXES.TABLE_SCHEMA = CURRENT SCHEMA and QSYS2.SYSINDEXES.TABLE_NAME = 'myTable'`, - oracle: `SELECT i.index_name,i.table_name, i.column_name, u.uniqueness, i.descend, c.constraint_type + oracle: `SELECT i.index_name,i.table_name, i.column_name, u.uniqueness, u.index_type, u.index_subtype, u.ityp_name, i.descend, c.constraint_type + FROM all_ind_columns i + INNER JOIN all_indexes u ON (u.table_name = i.table_name AND u.index_name = i.index_name) + LEFT OUTER JOIN all_constraints c ON (c.table_name = i.table_name AND c.index_name = i.index_name) + WHERE i.table_name = 'myTable' AND u.table_owner = '${dialect.getDefaultSchema()}' ORDER BY index_name, column_position`, + }); + }); + + it('uses a compatibility projection for Oracle versions without INDEX_SUBTYPE', () => { + if (dialect.name !== 'oracle') { + return; + } + + const oracle19Sequelize = createSequelizeInstance(); + oracle19Sequelize.setDatabaseVersion('19.0.0'); + const oracle19QueryGenerator = oracle19Sequelize.queryGenerator; + + expectsql(() => oracle19QueryGenerator.showIndexesQuery('myTable'), { + oracle: `SELECT i.index_name,i.table_name, i.column_name, u.uniqueness, u.index_type, u.ityp_name, i.descend, c.constraint_type FROM all_ind_columns i INNER JOIN all_indexes u ON (u.table_name = i.table_name AND u.index_name = i.index_name) LEFT OUTER JOIN all_constraints c ON (c.table_name = i.table_name AND c.index_name = i.index_name) @@ -63,7 +81,7 @@ describe('QueryGenerator#showIndexesQuery', () => { QSYS2.SYSKEYS.COLUMN_NAME, CAST('INDEX' AS VARCHAR(11)), QSYS2.SYSINDEXES.TABLE_SCHEMA, QSYS2.SYSINDEXES.TABLE_NAME from QSYS2.SYSKEYS left outer join QSYS2.SYSINDEXES on QSYS2.SYSKEYS.INDEX_NAME = QSYS2.SYSINDEXES.INDEX_NAME where QSYS2.SYSINDEXES.TABLE_SCHEMA = CURRENT SCHEMA and QSYS2.SYSINDEXES.TABLE_NAME = 'MyModels'`, - oracle: `SELECT i.index_name,i.table_name, i.column_name, u.uniqueness, i.descend, c.constraint_type + oracle: `SELECT i.index_name,i.table_name, i.column_name, u.uniqueness, u.index_type, u.index_subtype, u.ityp_name, i.descend, c.constraint_type FROM all_ind_columns i INNER JOIN all_indexes u ON (u.table_name = i.table_name AND u.index_name = i.index_name) LEFT OUTER JOIN all_constraints c ON (c.table_name = i.table_name AND c.index_name = i.index_name) @@ -98,7 +116,7 @@ describe('QueryGenerator#showIndexesQuery', () => { QSYS2.SYSKEYS.COLUMN_NAME, CAST('INDEX' AS VARCHAR(11)), QSYS2.SYSINDEXES.TABLE_SCHEMA, QSYS2.SYSINDEXES.TABLE_NAME from QSYS2.SYSKEYS left outer join QSYS2.SYSINDEXES on QSYS2.SYSKEYS.INDEX_NAME = QSYS2.SYSINDEXES.INDEX_NAME where QSYS2.SYSINDEXES.TABLE_SCHEMA = CURRENT SCHEMA and QSYS2.SYSINDEXES.TABLE_NAME = 'MyModels'`, - oracle: `SELECT i.index_name,i.table_name, i.column_name, u.uniqueness, i.descend, c.constraint_type FROM all_ind_columns i + oracle: `SELECT i.index_name,i.table_name, i.column_name, u.uniqueness, u.index_type, u.index_subtype, u.ityp_name, i.descend, c.constraint_type FROM all_ind_columns i INNER JOIN all_indexes u ON (u.table_name = i.table_name AND u.index_name = i.index_name) LEFT OUTER JOIN all_constraints c ON (c.table_name = i.table_name AND c.index_name = i.index_name) WHERE i.table_name = 'MyModels' AND u.table_owner = '${dialect.getDefaultSchema()}' ORDER BY index_name, column_position`, }); @@ -128,7 +146,7 @@ describe('QueryGenerator#showIndexesQuery', () => { QSYS2.SYSKEYS.COLUMN_NAME, CAST('INDEX' AS VARCHAR(11)), QSYS2.SYSINDEXES.TABLE_SCHEMA, QSYS2.SYSINDEXES.TABLE_NAME from QSYS2.SYSKEYS left outer join QSYS2.SYSINDEXES on QSYS2.SYSKEYS.INDEX_NAME = QSYS2.SYSINDEXES.INDEX_NAME where QSYS2.SYSINDEXES.TABLE_SCHEMA = 'mySchema' and QSYS2.SYSINDEXES.TABLE_NAME = 'myTable'`, - oracle: `SELECT i.index_name,i.table_name, i.column_name, u.uniqueness, i.descend, c.constraint_type + oracle: `SELECT i.index_name,i.table_name, i.column_name, u.uniqueness, u.index_type, u.index_subtype, u.ityp_name, i.descend, c.constraint_type FROM all_ind_columns i INNER JOIN all_indexes u ON (u.table_name = i.table_name AND u.index_name = i.index_name) LEFT OUTER JOIN all_constraints c ON (c.table_name = i.table_name AND c.index_name = i.index_name) @@ -166,7 +184,7 @@ describe('QueryGenerator#showIndexesQuery', () => { QSYS2.SYSKEYS.COLUMN_NAME, CAST('INDEX' AS VARCHAR(11)), QSYS2.SYSINDEXES.TABLE_SCHEMA, QSYS2.SYSINDEXES.TABLE_NAME from QSYS2.SYSKEYS left outer join QSYS2.SYSINDEXES on QSYS2.SYSKEYS.INDEX_NAME = QSYS2.SYSINDEXES.INDEX_NAME where QSYS2.SYSINDEXES.TABLE_SCHEMA = CURRENT SCHEMA and QSYS2.SYSINDEXES.TABLE_NAME = 'myTable'`, - oracle: `SELECT i.index_name,i.table_name, i.column_name, u.uniqueness, i.descend, c.constraint_type + oracle: `SELECT i.index_name,i.table_name, i.column_name, u.uniqueness, u.index_type, u.index_subtype, u.ityp_name, i.descend, c.constraint_type FROM all_ind_columns i INNER JOIN all_indexes u ON (u.table_name = i.table_name AND u.index_name = i.index_name) LEFT OUTER JOIN all_constraints c ON (c.table_name = i.table_name AND c.index_name = i.index_name) @@ -202,7 +220,7 @@ describe('QueryGenerator#showIndexesQuery', () => { QSYS2.SYSKEYS.COLUMN_NAME, CAST('INDEX' AS VARCHAR(11)), QSYS2.SYSINDEXES.TABLE_SCHEMA, QSYS2.SYSINDEXES.TABLE_NAME from QSYS2.SYSKEYS left outer join QSYS2.SYSINDEXES on QSYS2.SYSKEYS.INDEX_NAME = QSYS2.SYSINDEXES.INDEX_NAME where QSYS2.SYSINDEXES.TABLE_SCHEMA = 'mySchema' and QSYS2.SYSINDEXES.TABLE_NAME = 'myTable'`, - oracle: `SELECT i.index_name,i.table_name, i.column_name, u.uniqueness, i.descend, c.constraint_type + oracle: `SELECT i.index_name,i.table_name, i.column_name, u.uniqueness, u.index_type, u.index_subtype, u.ityp_name, i.descend, c.constraint_type FROM all_ind_columns i INNER JOIN all_indexes u ON (u.table_name = i.table_name AND u.index_name = i.index_name) LEFT OUTER JOIN all_constraints c ON (c.table_name = i.table_name AND c.index_name = i.index_name) diff --git a/packages/core/test/unit/sequelize.test.ts b/packages/core/test/unit/sequelize.test.ts index be37de1de609..61754265fa14 100644 --- a/packages/core/test/unit/sequelize.test.ts +++ b/packages/core/test/unit/sequelize.test.ts @@ -106,4 +106,21 @@ describe('Sequelize', () => { }); }); }); + + describe('vector helpers', () => { + it('throws on dialects that do not support VECTOR', () => { + if (sequelize.dialect.supports.dataTypes.VECTOR) { + const fn = sequelize.vectorDistance('embedding', [1, 2, 3]); + + expect(fn.fn).to.equal('VECTOR_DISTANCE'); + + return; + } + + expect(() => sequelize.vectorDistance('embedding', [1, 2, 3])).to.throw( + Error, + `vector_distance for dialect "${sequelize.dialect.name}" is not implemented`, + ); + }); + }); }); diff --git a/packages/oracle/src/_internal/data-types-overrides.ts b/packages/oracle/src/_internal/data-types-overrides.ts index 4abd25c8e309..c8d8d861adfb 100644 --- a/packages/oracle/src/_internal/data-types-overrides.ts +++ b/packages/oracle/src/_internal/data-types-overrides.ts @@ -183,6 +183,20 @@ export class DATE extends BaseTypes.DATE { } type AcceptedNumber = number | bigint | boolean | string | null; +export type OracleVectorFormat = 'INT8' | 'FLOAT32' | 'FLOAT64' | 'BINARY' | '*'; +export type OracleVectorStorage = 'SPARSE'; + +export interface OracleVectorOptions { + dimension?: number | '*'; + format?: OracleVectorFormat; + storage?: OracleVectorStorage; +} + +export interface OracleSparseVectorInput { + values: number[] | BaseTypes.NumericTypedArray; + indices: number[] | Uint32Array; + numDimensions: number; +} export class DECIMAL extends BaseTypes.DECIMAL { toSql() { @@ -419,3 +433,205 @@ export class DATEONLY extends BaseTypes.DATEONLY { return options.bindParam(value); } } + +/** + * Oracle VECTOR data type implementation. + */ +export class VECTOR extends BaseTypes.AbstractVECTORBase { + /** @hidden */ + static readonly [BaseTypes.DataTypeIdentifier]: string = 'VECTOR'; + + readonly options: OracleVectorOptions; + + constructor(); + constructor(dimension: number | '*', format?: string, storage?: string); + constructor(options: OracleVectorOptions); + constructor( + dimensionOrOptions?: number | '*' | OracleVectorOptions, + format?: string, + storage?: string, + ) { + super(); + + if (typeof dimensionOrOptions === 'object' && dimensionOrOptions !== null) { + this.options = { + ...(dimensionOrOptions.dimension !== undefined + ? { dimension: this._validateOracleDimension(dimensionOrOptions.dimension) } + : {}), + ...(dimensionOrOptions.format !== undefined + ? { format: this._validateFormat(dimensionOrOptions.format) } + : {}), + ...(dimensionOrOptions.storage !== undefined + ? { storage: this._validateStorage(dimensionOrOptions.storage) } + : {}), + }; + + return; + } + + this.options = { + ...(dimensionOrOptions !== undefined + ? { dimension: this._validateOracleDimension(dimensionOrOptions) } + : {}), + ...(format !== undefined ? { format: this._validateFormat(format) } : {}), + ...(storage !== undefined ? { storage: this._validateStorage(storage) } : {}), + }; + } + + /** + * Returns the SQL type name. + */ + protected _getTypeName(): string { + return 'VECTOR'; + } + + /** + * Validates the Oracle VECTOR format option. + * + * @param format + */ + protected _validateFormat(format: string): OracleVectorFormat { + const normalized = format.trim().toUpperCase(); + + switch (normalized) { + case 'INT8': + case 'FLOAT32': + case 'FLOAT64': + case 'BINARY': + case '*': + return normalized; + default: + throw new TypeError(`Invalid Oracle VECTOR format: ${format}`); + } + } + + /** + * Validates the Oracle VECTOR dimension option. + * + * @param dimension + */ + protected _validateOracleDimension(dimension: number | '*'): number | '*' { + if (dimension === '*') { + return dimension; + } + + return this._validateDimension(dimension); + } + + /** + * Validates the Oracle VECTOR storage option. + * + * @param storage + */ + protected _validateStorage(storage: string): OracleVectorStorage { + const normalized = storage.trim().toUpperCase(); + + switch (normalized) { + case 'SPARSE': + return normalized; + default: + throw new TypeError(`Invalid Oracle VECTOR storage: ${storage}`); + } + } + + /** + * Returns SQL option parts for Oracle VECTOR. + */ + protected _getSqlOptionParts(): string[] { + const options = this.options; + + return [ + ...(options.dimension !== undefined + ? [String(this._validateOracleDimension(options.dimension))] + : []), + ...(options.format !== undefined ? [this._validateFormat(options.format)] : []), + ...(options.storage !== undefined ? [this._validateStorage(options.storage)] : []), + ]; + } + + /** + * Validates supported dense and sparse vector input values. + * + * @param value + */ + validate(value: unknown): asserts value is BaseTypes.VectorValue | OracleSparseVectorInput { + if (isOracleSparseVectorInput(value)) { + return; + } + + super.validate(value); + } + + /** + * Converts values to Oracle bindable vector values. + * + * @param value + */ + toBindableValue(value: BaseTypes.VectorValue | OracleSparseVectorInput) { + if (Array.isArray(value)) { + return Float64Array.from(value); + } + + return value; + } + + /** + * Returns Oracle bind definition for vector values. + * + * @param oracledb + */ + _getBindDef(oracledb: Lib) { + return { type: oracledb.DB_TYPE_VECTOR }; + } +} + +/** + * Checks whether a value matches sparse vector input shape. + * + * @param value + */ +function isOracleSparseVectorInput(value: unknown): value is OracleSparseVectorInput { + if (typeof value !== 'object' || value === null) { + return false; + } + + const sparseVector = value as Partial; + const { indices, numDimensions, values } = sparseVector; + + return ( + isVectorComponent(values) && + isVectorComponent(indices) && + typeof numDimensions === 'number' && + Number.isInteger(numDimensions) && + numDimensions > 0 && + values.length === indices.length + ); +} + +/** + * Checks whether a value can be used as a sparse vector component. + * + * @param value + */ +function isVectorComponent(value: unknown): value is number[] | BaseTypes.NumericTypedArray { + return Array.isArray(value) || isNumericTypedArray(value); +} + +/** + * Checks whether a value is supported numeric typed array. + * + * @param value + */ +function isNumericTypedArray(value: unknown): value is BaseTypes.NumericTypedArray { + return ( + value instanceof Int8Array || + value instanceof Uint8Array || + value instanceof Uint8ClampedArray || + value instanceof Int16Array || + value instanceof Uint16Array || + value instanceof Int32Array || + value instanceof Uint32Array || + value instanceof Float32Array || + value instanceof Float64Array + ); +} diff --git a/packages/oracle/src/dialect.ts b/packages/oracle/src/dialect.ts index 6765ad0fac99..6d7b8eac7e44 100644 --- a/packages/oracle/src/dialect.ts +++ b/packages/oracle/src/dialect.ts @@ -49,6 +49,7 @@ export class OracleDialect extends AbstractDialect= 23; + } + /** * Returns the tableName and schemaName as it is stored the Oracle DB * diff --git a/packages/oracle/src/query-generator.internal.ts b/packages/oracle/src/query-generator.internal.ts index 5835eeb92050..4d4281a08dc6 100644 --- a/packages/oracle/src/query-generator.internal.ts +++ b/packages/oracle/src/query-generator.internal.ts @@ -6,8 +6,21 @@ import type { EscapeOptions } from '@sequelize/core/_non-semver-use-at-your-own- import type { AddLimitOffsetOptions } from '@sequelize/core/_non-semver-use-at-your-own-risk_/abstract-dialect/query-generator.internal-types.js'; import { wrapAmbiguousWhere } from '@sequelize/core/_non-semver-use-at-your-own-risk_/abstract-dialect/where-sql-builder.js'; import type { Cast } from '@sequelize/core/_non-semver-use-at-your-own-risk_/expression-builders/cast.js'; +import type { Fn } from '@sequelize/core/_non-semver-use-at-your-own-risk_/expression-builders/fn.js'; +import util from 'node:util'; import type { OracleDialect } from './dialect.js'; +const VECTOR_FUNCTIONS = new Set([ + 'COSINE_DISTANCE', + 'INNER_PRODUCT', + 'L1_DISTANCE', + 'L2_DISTANCE', + 'VECTOR_DISTANCE', +]); + +const VECTOR_ARG_ERROR = + 'expects the second argument to be a vector array, typed array, or VECTOR literal string'; + export class OracleQueryGeneratorInternal< Dialect extends OracleDialect = OracleDialect, > extends AbstractQueryGeneratorInternal { @@ -48,7 +61,108 @@ export class OracleQueryGeneratorInternal< return `CAST(${castSql} AS ${targetSql})`; } + formatFn(piece: Fn, options?: EscapeOptions): string { + const fnName = piece.fn.toUpperCase(); + if (!VECTOR_FUNCTIONS.has(fnName)) { + return super.formatFn(piece, options); + } + + if (piece.args.length !== 2) { + throw new Error(`${fnName} expects exactly 2 arguments`); + } + + const [columnArg, vectorArg] = piece.args; + const columnSql = + typeof columnArg === 'string' + ? this.queryGenerator.quoteIdentifier(columnArg) + : this.queryGenerator.escape(columnArg, options); + + return `${fnName}(${columnSql}, ${this.#formatVectorArg(vectorArg, fnName)})`; + } + + #formatVectorArg(arg: unknown, fnName: string): string { + if (Array.isArray(arg)) { + return this.#formatVectorFromIterable(arg); + } + + if (arg instanceof Float32Array || arg instanceof Float64Array || arg instanceof Uint8Array) { + return this.#formatVectorFromIterable(arg); + } + + if (typeof arg === 'string') { + const trimmed = arg.trim(); + const parsed = parseVectorLiteral(trimmed); + + if (parsed) { + return this.#formatVectorFromIterable(parsed); + } + + if (!looksLikeVectorLiteral(trimmed)) { + throw new Error(`${fnName} ${VECTOR_ARG_ERROR}`); + } + + throw new Error(`${fnName} expects a well-formed VECTOR literal string`); + } + + throw new Error(`${fnName} ${VECTOR_ARG_ERROR}`); + } + + // Oracle expects VECTOR('[1,2,3]') literals. Reuse the iterable path for both plain arrays + // and typed arrays so we only have one place that generates the comma-separated payload. + #formatVectorFromIterable(values: Iterable): string { + const parts: number[] = []; + for (const item of values) { + if (typeof item !== 'number' || !Number.isFinite(item)) { + throw new Error(`${util.format('%O is not a valid vector element', item)}`); + } + + parts.push(item); + } + + return `VECTOR('[${parts.join(',')}]')`; + } + getAliasToken(): string { return ''; } } + +function looksLikeVectorLiteral(input: string): boolean { + const trimmed = input.trim(); + + return trimmed.toUpperCase().startsWith('VECTOR(') && trimmed.endsWith(')'); +} + +function parseVectorLiteral(input: string): number[] | null { + const trimmed = input.trim(); + if (!looksLikeVectorLiteral(trimmed)) { + return null; + } + + const openParenIndex = trimmed.indexOf('('); + const inner = trimmed.slice(openParenIndex + 1, -1).trim(); + if (!inner.startsWith("'") || !inner.endsWith("'")) { + return null; + } + + const body = inner.slice(1, -1); + if (!body.startsWith('[') || !body.endsWith(']')) { + return null; + } + + let values: unknown; + try { + values = JSON.parse(body); + } catch { + return null; + } + + if ( + !Array.isArray(values) || + values.some(value => typeof value !== 'number' || !Number.isFinite(value)) + ) { + return null; + } + + return values; +} diff --git a/packages/oracle/src/query-generator.js b/packages/oracle/src/query-generator.js index 3183af01d6ae..ef5cb87130b2 100644 --- a/packages/oracle/src/query-generator.js +++ b/packages/oracle/src/query-generator.js @@ -7,6 +7,7 @@ import forOwn from 'lodash/forOwn'; import includes from 'lodash/includes'; import isPlainObject from 'lodash/isPlainObject'; import toPath from 'lodash/toPath'; +import util from 'node:util'; import oracledb from 'oracledb'; import { DataTypes } from '@sequelize/core'; @@ -15,6 +16,8 @@ import { ADD_COLUMN_QUERY_SUPPORTABLE_OPTIONS, CREATE_TABLE_QUERY_SUPPORTABLE_OPTIONS, } from '@sequelize/core/_non-semver-use-at-your-own-risk_/abstract-dialect/query-generator.js'; +import { BaseSqlExpression } from '@sequelize/core/_non-semver-use-at-your-own-risk_/expression-builders/base-sql-expression.js'; +import { conformIndex } from '@sequelize/core/_non-semver-use-at-your-own-risk_/model-internals.js'; import { rejectInvalidOptions } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/check.js'; import { quoteIdentifier } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/dialect.js'; import { joinSQLFragments } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/join-sql-fragments.js'; @@ -24,10 +27,52 @@ import { getObjectFromMap, } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/object.js'; import { defaultValueSchemable } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/query-builder-utils.js'; +import { nameIndex } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/string.js'; import { pojo } from '@sequelize/utils'; import { OracleQueryGeneratorTypeScript } from './query-generator-typescript.internal'; const CREATE_TABLE_QUERY_SUPPORTED_OPTIONS = new Set(['uniqueKeys']); +const VECTOR_ORGANIZATION_DEFAULT = 'hnsw'; +const VECTOR_INDEX_QUERY_SUPPORTABLE_OPTIONS = new Set([ + 'name', + 'parser', + 'type', + 'unique', + 'msg', + 'concurrently', + 'fields', + 'using', + 'operator', + 'where', + 'prefix', + 'distance', + 'accuracy', + 'parameter', + 'parallel', + 'include', +]); +const VECTOR_INDEX_QUERY_SUPPORTED_OPTIONS = new Set([ + 'name', + 'type', + 'fields', + 'using', + 'prefix', + 'distance', + 'accuracy', + 'parameter', + 'parallel', +]); +const VECTOR_INDEX_USING = new Set(['hnsw', 'ivf']); +const VECTOR_INDEX_USING_ERROR = 'Oracle VECTOR index using must be either "hnsw" or "ivf".'; +const VECTOR_INDEX_DISTANCE_METRICS = new Set([ + 'COSINE', + 'DOT', + 'EUCLIDEAN', + 'EUCLIDEAN_SQUARED', + 'HAMMING', + 'MANHATTAN', +]); +const VECTOR_INDEX_FIELD_ORDER_ERROR = 'Oracle VECTOR indexes do not support ordered fields.'; /** * list of reserved words in Oracle DB 21c @@ -404,11 +449,126 @@ export class OracleQueryGenerator extends OracleQueryGeneratorTypeScript { @overide */ addIndexQuery(tableName, attributes, options, rawTablename) { - if (typeof tableName !== 'string' && attributes.name) { + if (typeof tableName !== 'string' && attributes?.name) { attributes.name = `${tableName.schema}.${attributes.name}`; } - return super.addIndexQuery(tableName, attributes, options, rawTablename); + const requestedIndexType = toUpperCaseIfString(options?.type); + const requestedAttributeType = Array.isArray(attributes) + ? undefined + : toUpperCaseIfString(attributes?.type); + + if (requestedIndexType !== 'VECTOR' && requestedAttributeType !== 'VECTOR') { + return super.addIndexQuery(tableName, attributes, options, rawTablename); + } + + const normalizedOptions = this.#buildVectorIndexOptions( + tableName, + attributes, + options, + rawTablename, + requestedIndexType, + ); + + return joinSQLFragments([ + 'CREATE VECTOR INDEX', + this.quoteIdentifiers(normalizedOptions.name), + `ON ${this.quoteTable(tableName)}`, + `(${normalizedOptions.fieldsSql.join(', ')})`, + 'ORGANIZATION', + normalizedOptions.usingIsHnsw ? 'INMEMORY NEIGHBOR GRAPH' : 'NEIGHBOR PARTITIONS', + normalizedOptions.distance, + normalizedOptions.accuracy, + normalizedOptions.parameters, + normalizedOptions.parallel, + ]); + } + + /** + * Normalizes options and SQL fragments needed to build an Oracle VECTOR index. + * + * @param {string|object} tableName + * @param {Array|object|undefined} attributes + * @param {object|undefined} options + * @param {string|undefined} rawTablename + * @param {string|undefined} requestedIndexType + */ + #buildVectorIndexOptions(tableName, attributes, options, rawTablename, requestedIndexType) { + const normalizedOptions = Array.isArray(attributes) + ? { ...options, fields: attributes } + : { ...attributes }; + + if (requestedIndexType) { + normalizedOptions.type = requestedIndexType; + } + + normalizedOptions.prefix = sanitizePrefix( + normalizedOptions.prefix ?? rawTablename ?? tableName, + ); + + const fields = normalizedOptions.fields ?? []; + if (fields.length === 0) { + throw new Error('Vector indexes require at least one indexed field.'); + } + + rejectInvalidOptions( + 'addIndexQuery', + this.dialect, + VECTOR_INDEX_QUERY_SUPPORTABLE_OPTIONS, + VECTOR_INDEX_QUERY_SUPPORTED_OPTIONS, + normalizedOptions, + ); + + const fieldsSql = fields.map(field => { + if (field instanceof BaseSqlExpression) { + return this.formatSqlExpression(field); + } + + const normalizedField = typeof field === 'string' ? { name: field } : { ...field }; + + if (normalizedField.attribute) { + normalizedField.name = normalizedField.attribute; + } + + if (!normalizedField.name) { + throw new Error(`The following index field has no name: ${util.inspect(field)}`); + } + + if (normalizedField.order) { + throw new TypeError(VECTOR_INDEX_FIELD_ORDER_ERROR); + } + + return this.quoteIdentifier(normalizedField.name); + }); + + if (!normalizedOptions.name) { + normalizedOptions.name = nameIndex(normalizedOptions, normalizedOptions.prefix).name; + } + + const finalizedOptions = conformIndex(normalizedOptions); + const rawUsing = finalizedOptions.using; + if (rawUsing != null && typeof rawUsing !== 'string') { + throw new TypeError(VECTOR_INDEX_USING_ERROR); + } + + const using = rawUsing == null ? VECTOR_ORGANIZATION_DEFAULT : rawUsing.toLowerCase(); + validateVectorIndexUsing(using); + const distance = normalizeVectorIndexDistance(finalizedOptions.distance); + const accuracy = validateVectorIndexPositiveNumber(finalizedOptions.accuracy, 'accuracy'); + const parallel = validateVectorIndexPositiveInteger(finalizedOptions.parallel, 'parallel'); + const usingIsHnsw = using === 'hnsw'; + const parameterFragments = buildVectorParameters(finalizedOptions.parameter, usingIsHnsw); + + return { + name: finalizedOptions.name, + fieldsSql, + usingIsHnsw, + distance: distance ? `WITH DISTANCE ${distance}` : undefined, + accuracy: accuracy ? `WITH TARGET ACCURACY ${accuracy}` : undefined, + parameters: + parameterFragments.length > 0 ? `PARAMETERS (${parameterFragments.join(', ')})` : undefined, + parallel: parallel ? `PARALLEL ${parallel}` : undefined, + }; } // addConstraintQuery(tableName, options) { @@ -1165,6 +1325,123 @@ export class OracleQueryGenerator extends OracleQueryGeneratorTypeScript { } } +function toUpperCaseIfString(value) { + return typeof value === 'string' ? value.toUpperCase() : undefined; +} + +function sanitizePrefix(prefix) { + if (typeof prefix !== 'string') { + return prefix; + } + + return prefix.replaceAll('.', '_').replaceAll(/("|')/g, ''); +} + +function buildVectorParameters(parameter, usingIsHnsw) { + const parameters = []; + if (!parameter) { + return parameters; + } + + if (usingIsHnsw) { + parameters.push('type hnsw'); + const neighbor = validateVectorIndexPositiveInteger(parameter.neighbor, 'parameter.neighbor'); + const efconstruction = validateVectorIndexPositiveInteger( + parameter.efconstruction, + 'parameter.efconstruction', + ); + + if (neighbor) { + parameters.push(`neighbor ${neighbor}`); + } + + if (efconstruction) { + parameters.push(`efconstruction ${efconstruction}`); + } + + return parameters; + } + + parameters.push('type ivf'); + const partitions = validateVectorIndexPositiveInteger( + parameter.partitions, + 'parameter.partitions', + ); + const samplesPerPartition = validateVectorIndexPositiveInteger( + parameter.samplesPerPartition, + 'parameter.samplesPerPartition', + ); + const minVectors = validateVectorIndexPositiveInteger( + parameter.minVectors, + 'parameter.minVectors', + ); + + if (partitions) { + parameters.push(`NEIGHBOR PARTITIONS ${partitions}`); + } + + if (samplesPerPartition) { + parameters.push(`SAMPLES_PER_PARTITION ${samplesPerPartition}`); + } + + if (minVectors) { + parameters.push(`MIN_VECTORS_PER_PARTITION ${minVectors}`); + } + + return parameters; +} + +function validateVectorIndexUsing(using) { + if (VECTOR_INDEX_USING.has(using)) { + return; + } + + throw new TypeError(VECTOR_INDEX_USING_ERROR); +} + +function normalizeVectorIndexDistance(distance) { + if (distance == null) { + return undefined; + } + + if (typeof distance !== 'string') { + throw new TypeError('Oracle VECTOR index distance must be a string.'); + } + + const normalizedDistance = distance.toUpperCase(); + if (VECTOR_INDEX_DISTANCE_METRICS.has(normalizedDistance)) { + return normalizedDistance; + } + + throw new TypeError( + `Oracle VECTOR index distance must be one of: ${[...VECTOR_INDEX_DISTANCE_METRICS].join(', ')}.`, + ); +} + +function validateVectorIndexPositiveInteger(value, optionName) { + if (value == null) { + return undefined; + } + + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`Oracle VECTOR index ${optionName} must be a positive integer.`); + } + + return value; +} + +function validateVectorIndexPositiveNumber(value, optionName) { + if (value == null) { + return undefined; + } + + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new TypeError(`Oracle VECTOR index ${optionName} must be a positive number.`); + } + + return value; +} + /* istanbul ignore next */ function throwMethodUndefined(methodName) { throw new Error(`The method "${methodName}" is not defined! Please add it to your sql dialect.`); diff --git a/packages/oracle/src/query.js b/packages/oracle/src/query.js index ea99608113d4..f4cc12c9022e 100644 --- a/packages/oracle/src/query.js +++ b/packages/oracle/src/query.js @@ -663,11 +663,6 @@ export class OracleQuery extends AbstractQuery { return new DatabaseError(err); } - isShowIndexesQuery() { - // eslint-disable-next-line unicorn/prefer-includes - return this.sql.indexOf('SELECT i.index_name,i.table_name, i.column_name, u.uniqueness') > -1; - } - isSelectCountQuery() { return this.sql.toUpperCase().includes('SELECT COUNT('); } @@ -679,12 +674,26 @@ export class OracleQuery extends AbstractQuery { data.forEach(indexRecord => { // We create the object if (!acc[indexRecord.INDEX_NAME]) { + const oracleIndexType = indexRecord.INDEX_TYPE?.toUpperCase(); + let type; + let method; + + if (oracleIndexType === 'VECTOR') { + type = 'VECTOR'; + method = this.#determineVectorMethod(indexRecord.INDEX_SUBTYPE); + } else if (oracleIndexType === 'NORMAL' || !oracleIndexType) { + type = undefined; + } else { + type = oracleIndexType; + } + acc[indexRecord.INDEX_NAME] = { unique: indexRecord.UNIQUENESS === 'UNIQUE', primary: indexRecord.CONSTRAINT_TYPE === 'P', name: indexRecord.INDEX_NAME, tableName: indexRecord.TABLE_NAME.toLowerCase(), - type: undefined, + type, + method, }; acc[indexRecord.INDEX_NAME].fields = []; } @@ -722,6 +731,18 @@ export class OracleQuery extends AbstractQuery { return returnIndexes; } + // Oracle exposes vector index method in INDEX_SUBTYPE. + #determineVectorMethod(indexSubtype) { + const subtype = typeof indexSubtype === 'string' ? indexSubtype.toLowerCase() : undefined; + if (subtype?.includes('hnsw')) { + return 'hnsw'; + } + + if (subtype?.includes('ivf')) { + return 'ivf'; + } + } + handleInsertQuery(results, metaData) { if (this.instance && results && results.length > 0) { if ('pkReturnVal' in results[0]) { diff --git a/test/esm-named-exports.test.js b/test/esm-named-exports.test.js index cd2ac968f305..7fe7dd4d87cc 100644 --- a/test/esm-named-exports.test.js +++ b/test/esm-named-exports.test.js @@ -53,6 +53,7 @@ const ignoredCjsKeysMap = { 'TIME', 'TINYINT', 'TSVECTOR', + 'VECTOR', 'UUID', 'UUIDV1', 'UUIDV4', diff --git a/yarn.lock b/yarn.lock index ed963870961b..35ca6e1867cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1345,184 +1345,184 @@ __metadata: languageName: node linkType: hard -"@esbuild/aix-ppc64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/aix-ppc64@npm:0.28.0" +"@esbuild/aix-ppc64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/aix-ppc64@npm:0.28.1" conditions: os=aix & cpu=ppc64 languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/android-arm64@npm:0.28.0" +"@esbuild/android-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-arm64@npm:0.28.1" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@esbuild/android-arm@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/android-arm@npm:0.28.0" +"@esbuild/android-arm@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-arm@npm:0.28.1" conditions: os=android & cpu=arm languageName: node linkType: hard -"@esbuild/android-x64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/android-x64@npm:0.28.0" +"@esbuild/android-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-x64@npm:0.28.1" conditions: os=android & cpu=x64 languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/darwin-arm64@npm:0.28.0" +"@esbuild/darwin-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/darwin-arm64@npm:0.28.1" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/darwin-x64@npm:0.28.0" +"@esbuild/darwin-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/darwin-x64@npm:0.28.1" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/freebsd-arm64@npm:0.28.0" +"@esbuild/freebsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/freebsd-arm64@npm:0.28.1" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/freebsd-x64@npm:0.28.0" +"@esbuild/freebsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/freebsd-x64@npm:0.28.1" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/linux-arm64@npm:0.28.0" +"@esbuild/linux-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-arm64@npm:0.28.1" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/linux-arm@npm:0.28.0" +"@esbuild/linux-arm@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-arm@npm:0.28.1" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/linux-ia32@npm:0.28.0" +"@esbuild/linux-ia32@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-ia32@npm:0.28.1" conditions: os=linux & cpu=ia32 languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/linux-loong64@npm:0.28.0" +"@esbuild/linux-loong64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-loong64@npm:0.28.1" conditions: os=linux & cpu=loong64 languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/linux-mips64el@npm:0.28.0" +"@esbuild/linux-mips64el@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-mips64el@npm:0.28.1" conditions: os=linux & cpu=mips64el languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/linux-ppc64@npm:0.28.0" +"@esbuild/linux-ppc64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-ppc64@npm:0.28.1" conditions: os=linux & cpu=ppc64 languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/linux-riscv64@npm:0.28.0" +"@esbuild/linux-riscv64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-riscv64@npm:0.28.1" conditions: os=linux & cpu=riscv64 languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/linux-s390x@npm:0.28.0" +"@esbuild/linux-s390x@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-s390x@npm:0.28.1" conditions: os=linux & cpu=s390x languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/linux-x64@npm:0.28.0" +"@esbuild/linux-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-x64@npm:0.28.1" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@esbuild/netbsd-arm64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/netbsd-arm64@npm:0.28.0" +"@esbuild/netbsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/netbsd-arm64@npm:0.28.1" conditions: os=netbsd & cpu=arm64 languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/netbsd-x64@npm:0.28.0" +"@esbuild/netbsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/netbsd-x64@npm:0.28.1" conditions: os=netbsd & cpu=x64 languageName: node linkType: hard -"@esbuild/openbsd-arm64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/openbsd-arm64@npm:0.28.0" +"@esbuild/openbsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openbsd-arm64@npm:0.28.1" conditions: os=openbsd & cpu=arm64 languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/openbsd-x64@npm:0.28.0" +"@esbuild/openbsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openbsd-x64@npm:0.28.1" conditions: os=openbsd & cpu=x64 languageName: node linkType: hard -"@esbuild/openharmony-arm64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/openharmony-arm64@npm:0.28.0" +"@esbuild/openharmony-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openharmony-arm64@npm:0.28.1" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/sunos-x64@npm:0.28.0" +"@esbuild/sunos-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/sunos-x64@npm:0.28.1" conditions: os=sunos & cpu=x64 languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/win32-arm64@npm:0.28.0" +"@esbuild/win32-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-arm64@npm:0.28.1" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/win32-ia32@npm:0.28.0" +"@esbuild/win32-ia32@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-ia32@npm:0.28.1" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.28.0": - version: 0.28.0 - resolution: "@esbuild/win32-x64@npm:0.28.0" +"@esbuild/win32-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-x64@npm:0.28.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -3129,7 +3129,7 @@ __metadata: chai: "npm:4.5.0" concurrently: "npm:9.2.1" cross-env: "npm:10.1.0" - esbuild: "npm:0.28.0" + esbuild: "npm:0.28.1" eslint: "npm:8.57.1" eslint-plugin-jsdoc: "npm:61.7.1" eslint-plugin-mocha: "npm:10.5.0" @@ -7250,36 +7250,36 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:0.28.0": - version: 0.28.0 - resolution: "esbuild@npm:0.28.0" - dependencies: - "@esbuild/aix-ppc64": "npm:0.28.0" - "@esbuild/android-arm": "npm:0.28.0" - "@esbuild/android-arm64": "npm:0.28.0" - "@esbuild/android-x64": "npm:0.28.0" - "@esbuild/darwin-arm64": "npm:0.28.0" - "@esbuild/darwin-x64": "npm:0.28.0" - "@esbuild/freebsd-arm64": "npm:0.28.0" - "@esbuild/freebsd-x64": "npm:0.28.0" - "@esbuild/linux-arm": "npm:0.28.0" - "@esbuild/linux-arm64": "npm:0.28.0" - "@esbuild/linux-ia32": "npm:0.28.0" - "@esbuild/linux-loong64": "npm:0.28.0" - "@esbuild/linux-mips64el": "npm:0.28.0" - "@esbuild/linux-ppc64": "npm:0.28.0" - "@esbuild/linux-riscv64": "npm:0.28.0" - "@esbuild/linux-s390x": "npm:0.28.0" - "@esbuild/linux-x64": "npm:0.28.0" - "@esbuild/netbsd-arm64": "npm:0.28.0" - "@esbuild/netbsd-x64": "npm:0.28.0" - "@esbuild/openbsd-arm64": "npm:0.28.0" - "@esbuild/openbsd-x64": "npm:0.28.0" - "@esbuild/openharmony-arm64": "npm:0.28.0" - "@esbuild/sunos-x64": "npm:0.28.0" - "@esbuild/win32-arm64": "npm:0.28.0" - "@esbuild/win32-ia32": "npm:0.28.0" - "@esbuild/win32-x64": "npm:0.28.0" +"esbuild@npm:0.28.1": + version: 0.28.1 + resolution: "esbuild@npm:0.28.1" + dependencies: + "@esbuild/aix-ppc64": "npm:0.28.1" + "@esbuild/android-arm": "npm:0.28.1" + "@esbuild/android-arm64": "npm:0.28.1" + "@esbuild/android-x64": "npm:0.28.1" + "@esbuild/darwin-arm64": "npm:0.28.1" + "@esbuild/darwin-x64": "npm:0.28.1" + "@esbuild/freebsd-arm64": "npm:0.28.1" + "@esbuild/freebsd-x64": "npm:0.28.1" + "@esbuild/linux-arm": "npm:0.28.1" + "@esbuild/linux-arm64": "npm:0.28.1" + "@esbuild/linux-ia32": "npm:0.28.1" + "@esbuild/linux-loong64": "npm:0.28.1" + "@esbuild/linux-mips64el": "npm:0.28.1" + "@esbuild/linux-ppc64": "npm:0.28.1" + "@esbuild/linux-riscv64": "npm:0.28.1" + "@esbuild/linux-s390x": "npm:0.28.1" + "@esbuild/linux-x64": "npm:0.28.1" + "@esbuild/netbsd-arm64": "npm:0.28.1" + "@esbuild/netbsd-x64": "npm:0.28.1" + "@esbuild/openbsd-arm64": "npm:0.28.1" + "@esbuild/openbsd-x64": "npm:0.28.1" + "@esbuild/openharmony-arm64": "npm:0.28.1" + "@esbuild/sunos-x64": "npm:0.28.1" + "@esbuild/win32-arm64": "npm:0.28.1" + "@esbuild/win32-ia32": "npm:0.28.1" + "@esbuild/win32-x64": "npm:0.28.1" dependenciesMeta: "@esbuild/aix-ppc64": optional: true @@ -7335,7 +7335,7 @@ __metadata: optional: true bin: esbuild: bin/esbuild - checksum: 10c0/8acd95c238ec6c4a9d16163277faf228a8994b642d187b3fe667ffbb469008e6748cde144fdc3c175bf8e78ee49e15a0ed9b9f183fdb5fcea1772f87fb1372a4 + checksum: 10c0/29cd456a79ce35ac2c7e05fe871330416b2c395c045d849653f843e51378d6e0d6e774d6dcd01b35f4e83238a29bf8decd04fcd34b3780c589a250b21e5f92bb languageName: node linkType: hard