feat: Integration Hub for external tool connectivity
- Add native config option: `new Brainy({ integrations: true })`
- OData integration for Excel Power Query and Power BI
- Google Sheets integration with Apps Script
- Server-Sent Events (SSE) for real-time streaming
- Webhooks for push notifications
- Zero-config with sensible defaults
- Full tree-shaking when disabled
This commit is contained in:
parent
24039e8a1a
commit
b5bc9000cf
28 changed files with 8186 additions and 1 deletions
238
src/integrations/odata/EdmxGenerator.ts
Normal file
238
src/integrations/odata/EdmxGenerator.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* EDMX Metadata Generator
|
||||
*
|
||||
* Generates OData $metadata XML document describing the Brainy data model.
|
||||
* This allows tools like Excel Power Query and Power BI to discover the schema.
|
||||
*/
|
||||
|
||||
import { NounType, VerbType } from '../../types/graphTypes.js'
|
||||
|
||||
/**
|
||||
* OData property type mapping
|
||||
*/
|
||||
interface PropertyDef {
|
||||
name: string
|
||||
type: string
|
||||
nullable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate EDMX metadata XML for Brainy schema
|
||||
*
|
||||
* @param options Configuration options
|
||||
* @returns XML string
|
||||
*/
|
||||
export function generateEdmx(options?: {
|
||||
namespace?: string
|
||||
containerName?: string
|
||||
includeRelationships?: boolean
|
||||
}): string {
|
||||
const namespace = options?.namespace ?? 'Brainy'
|
||||
const containerName = options?.containerName ?? 'BrainyContainer'
|
||||
const includeRelationships = options?.includeRelationships ?? true
|
||||
|
||||
const entityProperties = getEntityProperties()
|
||||
const relationshipProperties = getRelationshipProperties()
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
|
||||
<edmx:DataServices>
|
||||
<Schema Namespace="${namespace}" xmlns="http://docs.oasis-open.org/odata/ns/edm">
|
||||
|
||||
<!-- Entity Type -->
|
||||
<EntityType Name="Entity">
|
||||
<Key>
|
||||
<PropertyRef Name="Id"/>
|
||||
</Key>
|
||||
${entityProperties.map((p) => ` <Property Name="${p.name}" Type="${p.type}" Nullable="${p.nullable}"/>`).join('\n')}
|
||||
</EntityType>
|
||||
|
||||
${includeRelationships ? generateRelationshipType(relationshipProperties) : ''}
|
||||
|
||||
<!-- Enum: NounType -->
|
||||
<EnumType Name="NounType">
|
||||
${Object.values(NounType)
|
||||
.map((v, i) => ` <Member Name="${v}" Value="${i}"/>`)
|
||||
.join('\n')}
|
||||
</EnumType>
|
||||
|
||||
${includeRelationships ? generateVerbTypeEnum() : ''}
|
||||
|
||||
<!-- Entity Container -->
|
||||
<EntityContainer Name="${containerName}">
|
||||
<EntitySet Name="Entities" EntityType="${namespace}.Entity"/>
|
||||
${includeRelationships ? ` <EntitySet Name="Relationships" EntityType="${namespace}.Relationship"/>` : ''}
|
||||
</EntityContainer>
|
||||
|
||||
</Schema>
|
||||
</edmx:DataServices>
|
||||
</edmx:Edmx>`
|
||||
|
||||
return xml
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JSON-based OData metadata (for modern clients)
|
||||
*/
|
||||
export function generateMetadataJson(options?: {
|
||||
namespace?: string
|
||||
includeRelationships?: boolean
|
||||
}): object {
|
||||
const namespace = options?.namespace ?? 'Brainy'
|
||||
const includeRelationships = options?.includeRelationships ?? true
|
||||
|
||||
const entityProps = getEntityProperties()
|
||||
const relProps = getRelationshipProperties()
|
||||
|
||||
const schema: any = {
|
||||
$Version: '4.0',
|
||||
[`${namespace}`]: {
|
||||
Entity: {
|
||||
$Kind: 'EntityType',
|
||||
$Key: ['Id'],
|
||||
...Object.fromEntries(
|
||||
entityProps.map((p) => [
|
||||
p.name,
|
||||
{
|
||||
$Type: p.type.replace('Edm.', ''),
|
||||
$Nullable: p.nullable
|
||||
}
|
||||
])
|
||||
)
|
||||
},
|
||||
NounType: {
|
||||
$Kind: 'EnumType',
|
||||
...Object.fromEntries(
|
||||
Object.values(NounType).map((v, i) => [v, i])
|
||||
)
|
||||
}
|
||||
},
|
||||
[`${namespace}.Container`]: {
|
||||
$Kind: 'EntityContainer',
|
||||
Entities: {
|
||||
$Collection: true,
|
||||
$Type: `${namespace}.Entity`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeRelationships) {
|
||||
schema[namespace].Relationship = {
|
||||
$Kind: 'EntityType',
|
||||
$Key: ['Id'],
|
||||
...Object.fromEntries(
|
||||
relProps.map((p) => [
|
||||
p.name,
|
||||
{
|
||||
$Type: p.type.replace('Edm.', ''),
|
||||
$Nullable: p.nullable
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
schema[namespace].VerbType = {
|
||||
$Kind: 'EnumType',
|
||||
...Object.fromEntries(
|
||||
Object.values(VerbType).map((v, i) => [v, i])
|
||||
)
|
||||
}
|
||||
|
||||
schema[`${namespace}.Container`].Relationships = {
|
||||
$Collection: true,
|
||||
$Type: `${namespace}.Relationship`
|
||||
}
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service document (root OData response)
|
||||
*/
|
||||
export function generateServiceDocument(
|
||||
baseUrl: string,
|
||||
options?: { includeRelationships?: boolean }
|
||||
): object {
|
||||
const includeRelationships = options?.includeRelationships ?? true
|
||||
|
||||
const collections = [
|
||||
{
|
||||
name: 'Entities',
|
||||
kind: 'EntitySet',
|
||||
url: 'Entities'
|
||||
}
|
||||
]
|
||||
|
||||
if (includeRelationships) {
|
||||
collections.push({
|
||||
name: 'Relationships',
|
||||
kind: 'EntitySet',
|
||||
url: 'Relationships'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
'@odata.context': `${baseUrl}/$metadata`,
|
||||
value: collections
|
||||
}
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
function getEntityProperties(): PropertyDef[] {
|
||||
return [
|
||||
{ name: 'Id', type: 'Edm.String', nullable: false },
|
||||
{ name: 'Type', type: 'Edm.String', nullable: false },
|
||||
{ name: 'CreatedAt', type: 'Edm.DateTimeOffset', nullable: false },
|
||||
{ name: 'UpdatedAt', type: 'Edm.DateTimeOffset', nullable: true },
|
||||
{ name: 'Confidence', type: 'Edm.Double', nullable: true },
|
||||
{ name: 'Weight', type: 'Edm.Double', nullable: true },
|
||||
{ name: 'Service', type: 'Edm.String', nullable: true },
|
||||
{ name: 'Data', type: 'Edm.String', nullable: true },
|
||||
// Common metadata fields (flattened)
|
||||
{ name: 'Metadata_name', type: 'Edm.String', nullable: true },
|
||||
{ name: 'Metadata_title', type: 'Edm.String', nullable: true },
|
||||
{ name: 'Metadata_description', type: 'Edm.String', nullable: true },
|
||||
{ name: 'Metadata_email', type: 'Edm.String', nullable: true },
|
||||
{ name: 'Metadata_url', type: 'Edm.String', nullable: true },
|
||||
{ name: 'Metadata_tags', type: 'Edm.String', nullable: true },
|
||||
{ name: 'Metadata_category', type: 'Edm.String', nullable: true },
|
||||
{ name: 'Metadata_status', type: 'Edm.String', nullable: true },
|
||||
{ name: 'Metadata_priority', type: 'Edm.Int32', nullable: true }
|
||||
]
|
||||
}
|
||||
|
||||
function getRelationshipProperties(): PropertyDef[] {
|
||||
return [
|
||||
{ name: 'Id', type: 'Edm.String', nullable: false },
|
||||
{ name: 'FromId', type: 'Edm.String', nullable: false },
|
||||
{ name: 'ToId', type: 'Edm.String', nullable: false },
|
||||
{ name: 'Type', type: 'Edm.String', nullable: false },
|
||||
{ name: 'Weight', type: 'Edm.Double', nullable: true },
|
||||
{ name: 'Confidence', type: 'Edm.Double', nullable: true },
|
||||
{ name: 'CreatedAt', type: 'Edm.DateTimeOffset', nullable: false },
|
||||
{ name: 'UpdatedAt', type: 'Edm.DateTimeOffset', nullable: true },
|
||||
{ name: 'Service', type: 'Edm.String', nullable: true },
|
||||
{ name: 'Metadata', type: 'Edm.String', nullable: true }
|
||||
]
|
||||
}
|
||||
|
||||
function generateRelationshipType(properties: PropertyDef[]): string {
|
||||
return ` <!-- Relationship Type -->
|
||||
<EntityType Name="Relationship">
|
||||
<Key>
|
||||
<PropertyRef Name="Id"/>
|
||||
</Key>
|
||||
${properties.map((p) => ` <Property Name="${p.name}" Type="${p.type}" Nullable="${p.nullable}"/>`).join('\n')}
|
||||
</EntityType>`
|
||||
}
|
||||
|
||||
function generateVerbTypeEnum(): string {
|
||||
return ` <!-- Enum: VerbType -->
|
||||
<EnumType Name="VerbType">
|
||||
${Object.values(VerbType)
|
||||
.map((v, i) => ` <Member Name="${v}" Value="${i}"/>`)
|
||||
.join('\n')}
|
||||
</EnumType>`
|
||||
}
|
||||
710
src/integrations/odata/ODataIntegration.ts
Normal file
710
src/integrations/odata/ODataIntegration.ts
Normal file
|
|
@ -0,0 +1,710 @@
|
|||
/**
|
||||
* OData Integration
|
||||
*
|
||||
* Exposes Brainy data via OData 4.0 REST API for:
|
||||
* - Excel Power Query
|
||||
* - Power BI
|
||||
* - Tableau
|
||||
* - Any OData-compatible BI tool
|
||||
*
|
||||
* Zero external dependencies - works in all environments.
|
||||
*/
|
||||
|
||||
import {
|
||||
IntegrationBase,
|
||||
HTTPIntegration
|
||||
} from '../core/IntegrationBase.js'
|
||||
import { IntegrationConfig, ODataQueryOptions } from '../core/types.js'
|
||||
import { AugmentationManifest } from '../../augmentations/manifest.js'
|
||||
import { Entity, Relation, FindParams } from '../../types/brainy.types.js'
|
||||
import { NounType } from '../../types/graphTypes.js'
|
||||
import {
|
||||
parseODataQuery,
|
||||
applyFilter,
|
||||
applySelect,
|
||||
applyOrderBy,
|
||||
applyPagination
|
||||
} from './ODataQueryParser.js'
|
||||
import {
|
||||
generateEdmx,
|
||||
generateMetadataJson,
|
||||
generateServiceDocument
|
||||
} from './EdmxGenerator.js'
|
||||
|
||||
/**
|
||||
* OData integration configuration
|
||||
*/
|
||||
export interface ODataConfig extends IntegrationConfig {
|
||||
/** Base path for OData routes (default: '/odata') */
|
||||
basePath?: string
|
||||
|
||||
/** Port to listen on (only used when running standalone server) */
|
||||
port?: number
|
||||
|
||||
/** Include relationships endpoint (default: true) */
|
||||
includeRelationships?: boolean
|
||||
|
||||
/** Maximum page size (default: 1000) */
|
||||
maxPageSize?: number
|
||||
|
||||
/** Default page size (default: 100) */
|
||||
defaultPageSize?: number
|
||||
|
||||
/** Namespace for metadata (default: 'Brainy') */
|
||||
namespace?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* OData request context
|
||||
*/
|
||||
interface ODataRequest {
|
||||
method: string
|
||||
path: string
|
||||
query: string
|
||||
body?: any
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* OData response
|
||||
*/
|
||||
interface ODataResponse {
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
body: any
|
||||
}
|
||||
|
||||
/**
|
||||
* OData Integration
|
||||
*
|
||||
* Provides OData 4.0 REST API for Brainy data access from spreadsheets and BI tools.
|
||||
*
|
||||
* Routes:
|
||||
* - GET /odata - Service document
|
||||
* - GET /odata/$metadata - EDMX metadata
|
||||
* - GET /odata/Entities - List entities with OData queries
|
||||
* - GET /odata/Entities('id') - Get single entity
|
||||
* - GET /odata/Relationships - List relationships
|
||||
* - GET /odata/Relationships('id') - Get single relationship
|
||||
* - POST /odata/Entities - Create entity
|
||||
* - PATCH /odata/Entities('id') - Update entity
|
||||
* - DELETE /odata/Entities('id') - Delete entity
|
||||
*
|
||||
* Supports: $filter, $select, $orderby, $top, $skip, $count, $search
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Register with Brainy
|
||||
* brain.augmentations.register(new ODataIntegration({
|
||||
* basePath: '/odata',
|
||||
* maxPageSize: 1000
|
||||
* }))
|
||||
*
|
||||
* // Connect from Excel Power Query:
|
||||
* // Data → Get Data → From OData Feed → http://localhost:3000/odata
|
||||
* ```
|
||||
*/
|
||||
export class ODataIntegration extends IntegrationBase implements HTTPIntegration {
|
||||
readonly name = 'odata'
|
||||
|
||||
// HTTPIntegration
|
||||
port: number
|
||||
basePath: string
|
||||
|
||||
private odataConfig: ODataConfig & {
|
||||
enabled: boolean
|
||||
basePath: string
|
||||
port: number
|
||||
includeRelationships: boolean
|
||||
maxPageSize: number
|
||||
defaultPageSize: number
|
||||
namespace: string
|
||||
}
|
||||
|
||||
constructor(config?: ODataConfig) {
|
||||
super(config)
|
||||
|
||||
this.odataConfig = {
|
||||
enabled: config?.enabled ?? true,
|
||||
basePath: config?.basePath ?? '/odata',
|
||||
port: config?.port ?? 0,
|
||||
includeRelationships: config?.includeRelationships ?? true,
|
||||
maxPageSize: config?.maxPageSize ?? 1000,
|
||||
defaultPageSize: config?.defaultPageSize ?? 100,
|
||||
namespace: config?.namespace ?? 'Brainy',
|
||||
rateLimit: config?.rateLimit,
|
||||
auth: config?.auth,
|
||||
cors: config?.cors
|
||||
}
|
||||
|
||||
this.port = this.odataConfig.port
|
||||
this.basePath = this.odataConfig.basePath
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the integration (registers routes with API server)
|
||||
*/
|
||||
protected async onStart(): Promise<void> {
|
||||
this.log('OData integration started')
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the integration
|
||||
*/
|
||||
protected async onStop(): Promise<void> {
|
||||
this.log('OData integration stopped')
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an OData request
|
||||
*
|
||||
* This is the main entry point for processing OData requests.
|
||||
* Can be called directly or integrated with an HTTP server.
|
||||
*
|
||||
* @param request OData request
|
||||
* @returns OData response
|
||||
*/
|
||||
async handleRequest(request: ODataRequest): Promise<ODataResponse> {
|
||||
this.recordRequest()
|
||||
|
||||
try {
|
||||
const { method, path } = request
|
||||
const relativePath = path.startsWith(this.basePath)
|
||||
? path.slice(this.basePath.length)
|
||||
: path
|
||||
|
||||
// Route the request
|
||||
if (method === 'GET') {
|
||||
if (relativePath === '' || relativePath === '/') {
|
||||
return this.getServiceDocument(request)
|
||||
}
|
||||
if (relativePath === '/$metadata') {
|
||||
return this.getMetadata(request)
|
||||
}
|
||||
if (relativePath.startsWith('/Entities')) {
|
||||
return this.handleEntities(request, relativePath)
|
||||
}
|
||||
if (relativePath.startsWith('/Relationships')) {
|
||||
return this.handleRelationships(request, relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'POST') {
|
||||
if (relativePath === '/Entities') {
|
||||
return this.createEntity(request)
|
||||
}
|
||||
if (relativePath === '/Relationships') {
|
||||
return this.createRelationship(request)
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'PATCH') {
|
||||
if (relativePath.startsWith('/Entities(')) {
|
||||
return this.updateEntity(request, relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
if (relativePath.startsWith('/Entities(')) {
|
||||
return this.deleteEntity(request, relativePath)
|
||||
}
|
||||
if (relativePath.startsWith('/Relationships(')) {
|
||||
return this.deleteRelationship(request, relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
return this.errorResponse(404, 'Not Found')
|
||||
} catch (error: any) {
|
||||
this.recordError(error)
|
||||
return this.errorResponse(500, error.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registered routes
|
||||
*/
|
||||
getRoutes(): Array<{ method: string; path: string; description: string }> {
|
||||
const routes = [
|
||||
{ method: 'GET', path: `${this.basePath}`, description: 'Service document' },
|
||||
{ method: 'GET', path: `${this.basePath}/$metadata`, description: 'EDMX metadata' },
|
||||
{ method: 'GET', path: `${this.basePath}/Entities`, description: 'List entities' },
|
||||
{ method: 'GET', path: `${this.basePath}/Entities('id')`, description: 'Get entity' },
|
||||
{ method: 'POST', path: `${this.basePath}/Entities`, description: 'Create entity' },
|
||||
{ method: 'PATCH', path: `${this.basePath}/Entities('id')`, description: 'Update entity' },
|
||||
{ method: 'DELETE', path: `${this.basePath}/Entities('id')`, description: 'Delete entity' }
|
||||
]
|
||||
|
||||
if (this.odataConfig.includeRelationships) {
|
||||
routes.push(
|
||||
{ method: 'GET', path: `${this.basePath}/Relationships`, description: 'List relationships' },
|
||||
{ method: 'GET', path: `${this.basePath}/Relationships('id')`, description: 'Get relationship' },
|
||||
{ method: 'POST', path: `${this.basePath}/Relationships`, description: 'Create relationship' },
|
||||
{ method: 'DELETE', path: `${this.basePath}/Relationships('id')`, description: 'Delete relationship' }
|
||||
)
|
||||
}
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get augmentation manifest
|
||||
*/
|
||||
getManifest(): AugmentationManifest {
|
||||
return {
|
||||
id: 'odata',
|
||||
name: 'OData Integration',
|
||||
version: '1.0.0',
|
||||
description: 'OData 4.0 API for Excel, Power BI, and BI tools',
|
||||
longDescription:
|
||||
'Exposes Brainy data via OData 4.0 REST API, enabling direct connections from Excel Power Query, Power BI, Tableau, and any OData-compatible tool. Supports $filter, $select, $orderby, $top, $skip, $count queries.',
|
||||
category: 'integration',
|
||||
status: 'stable',
|
||||
configSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
enabled: { type: 'boolean', default: true },
|
||||
basePath: { type: 'string', default: '/odata' },
|
||||
maxPageSize: { type: 'number', default: 1000 },
|
||||
defaultPageSize: { type: 'number', default: 100 },
|
||||
includeRelationships: { type: 'boolean', default: true },
|
||||
namespace: { type: 'string', default: 'Brainy' }
|
||||
}
|
||||
},
|
||||
configDefaults: {
|
||||
enabled: true,
|
||||
basePath: '/odata',
|
||||
maxPageSize: 1000,
|
||||
defaultPageSize: 100,
|
||||
includeRelationships: true,
|
||||
namespace: 'Brainy'
|
||||
},
|
||||
features: [
|
||||
'Excel Power Query support',
|
||||
'Power BI direct connect',
|
||||
'OData $filter queries',
|
||||
'OData $select, $orderby',
|
||||
'Pagination with $top/$skip',
|
||||
'$count support',
|
||||
'$search semantic search'
|
||||
],
|
||||
keywords: ['odata', 'excel', 'power-bi', 'bi', 'rest', 'api']
|
||||
}
|
||||
}
|
||||
|
||||
// Route handlers
|
||||
|
||||
private getServiceDocument(_request: ODataRequest): ODataResponse {
|
||||
const baseUrl = `${this.basePath}`
|
||||
return this.jsonResponse(
|
||||
generateServiceDocument(baseUrl, {
|
||||
includeRelationships: this.odataConfig.includeRelationships
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private getMetadata(request: ODataRequest): ODataResponse {
|
||||
// Check Accept header for JSON vs XML
|
||||
const accept = request.headers['accept'] || ''
|
||||
|
||||
if (accept.includes('application/json')) {
|
||||
return this.jsonResponse(
|
||||
generateMetadataJson({
|
||||
namespace: this.odataConfig.namespace,
|
||||
includeRelationships: this.odataConfig.includeRelationships
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Default to XML EDMX
|
||||
return {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'OData-Version': '4.0'
|
||||
},
|
||||
body: generateEdmx({
|
||||
namespace: this.odataConfig.namespace,
|
||||
includeRelationships: this.odataConfig.includeRelationships
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async handleEntities(
|
||||
request: ODataRequest,
|
||||
path: string
|
||||
): Promise<ODataResponse> {
|
||||
// Single entity: /Entities('id')
|
||||
const idMatch = path.match(/^\/Entities\('([^']+)'\)/)
|
||||
if (idMatch) {
|
||||
return this.handleGetEntity(idMatch[1])
|
||||
}
|
||||
|
||||
// Collection: /Entities?$filter=...
|
||||
return this.listEntities(request)
|
||||
}
|
||||
|
||||
private async listEntities(request: ODataRequest): Promise<ODataResponse> {
|
||||
const options = parseODataQuery(request.query)
|
||||
|
||||
// Apply pagination limits
|
||||
const pageSize = Math.min(
|
||||
options.top ?? this.odataConfig.defaultPageSize,
|
||||
this.odataConfig.maxPageSize
|
||||
)
|
||||
options.top = pageSize
|
||||
|
||||
// Query from Brainy
|
||||
const findParams: FindParams = {
|
||||
limit: pageSize + 1, // +1 to detect if there are more
|
||||
offset: options.skip
|
||||
}
|
||||
|
||||
// Apply orderby
|
||||
if (options.orderBy && options.orderBy.length > 0) {
|
||||
findParams.orderBy = options.orderBy[0].field
|
||||
findParams.order = options.orderBy[0].direction
|
||||
}
|
||||
|
||||
// Apply $search as semantic query
|
||||
if (options.search) {
|
||||
findParams.query = options.search
|
||||
}
|
||||
|
||||
// Get entities
|
||||
let entities = await this.queryEntities(findParams)
|
||||
|
||||
// Apply $filter (in-memory for complex filters)
|
||||
if (options.filter) {
|
||||
const rows = this.exporter.entitiesToRows(entities)
|
||||
const filteredRows = applyFilter(rows, options.filter)
|
||||
// Map back to entities (simple approach)
|
||||
const filteredIds = new Set(filteredRows.map((r) => r.Id))
|
||||
entities = entities.filter((e) => filteredIds.has(e.id))
|
||||
}
|
||||
|
||||
// Check for more results
|
||||
const hasMore = entities.length > pageSize
|
||||
if (hasMore) {
|
||||
entities = entities.slice(0, pageSize)
|
||||
}
|
||||
|
||||
// Convert to tabular format
|
||||
let rows = this.exporter.entitiesToRows(entities)
|
||||
|
||||
// Apply $select
|
||||
if (options.select && options.select.length > 0) {
|
||||
rows = applySelect(rows, options.select) as any
|
||||
}
|
||||
|
||||
// Apply $orderby (if not handled by storage)
|
||||
if (options.orderBy && options.orderBy.length > 0) {
|
||||
rows = applyOrderBy(rows, options.orderBy)
|
||||
}
|
||||
|
||||
// Build response
|
||||
const result: any = {
|
||||
'@odata.context': `${this.basePath}/$metadata#Entities`
|
||||
}
|
||||
|
||||
// $count
|
||||
if (options.count) {
|
||||
// For accurate count, we'd need to query without limit
|
||||
// For now, use the page count
|
||||
result['@odata.count'] = entities.length
|
||||
}
|
||||
|
||||
result.value = rows
|
||||
|
||||
// Next link for pagination
|
||||
if (hasMore) {
|
||||
const nextSkip = (options.skip ?? 0) + pageSize
|
||||
result['@odata.nextLink'] = `${this.basePath}/Entities?$top=${pageSize}&$skip=${nextSkip}`
|
||||
}
|
||||
|
||||
return this.jsonResponse(result)
|
||||
}
|
||||
|
||||
private async handleGetEntity(id: string): Promise<ODataResponse> {
|
||||
const entity = await super.getEntity(id)
|
||||
|
||||
if (!entity) {
|
||||
return this.errorResponse(404, `Entity '${id}' not found`)
|
||||
}
|
||||
|
||||
const row = this.exporter.entityToRow(entity)
|
||||
|
||||
return this.jsonResponse({
|
||||
'@odata.context': `${this.basePath}/$metadata#Entities/$entity`,
|
||||
...row
|
||||
})
|
||||
}
|
||||
|
||||
private async createEntity(request: ODataRequest): Promise<ODataResponse> {
|
||||
if (!this.context) {
|
||||
return this.errorResponse(500, 'Integration not initialized')
|
||||
}
|
||||
|
||||
const body = request.body
|
||||
if (!body || !body.Type) {
|
||||
return this.errorResponse(400, 'Missing required field: Type')
|
||||
}
|
||||
|
||||
const entity = await this.context.brain.add({
|
||||
type: body.Type as NounType,
|
||||
data: body.Data ? JSON.parse(body.Data) : undefined,
|
||||
metadata: this.extractMetadata(body),
|
||||
confidence: body.Confidence,
|
||||
weight: body.Weight,
|
||||
service: body.Service
|
||||
})
|
||||
|
||||
const row = this.exporter.entityToRow(entity)
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'OData-Version': '4.0',
|
||||
'Location': `${this.basePath}/Entities('${entity.id}')`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'@odata.context': `${this.basePath}/$metadata#Entities/$entity`,
|
||||
...row
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async updateEntity(
|
||||
request: ODataRequest,
|
||||
path: string
|
||||
): Promise<ODataResponse> {
|
||||
if (!this.context) {
|
||||
return this.errorResponse(500, 'Integration not initialized')
|
||||
}
|
||||
|
||||
const idMatch = path.match(/^\/Entities\('([^']+)'\)/)
|
||||
if (!idMatch) {
|
||||
return this.errorResponse(400, 'Invalid entity ID')
|
||||
}
|
||||
|
||||
const id = idMatch[1]
|
||||
const body = request.body
|
||||
|
||||
const updates: any = { id }
|
||||
|
||||
if (body.Type) updates.type = body.Type as NounType
|
||||
if (body.Data) updates.data = JSON.parse(body.Data)
|
||||
if (body.Confidence !== undefined) updates.confidence = body.Confidence
|
||||
if (body.Weight !== undefined) updates.weight = body.Weight
|
||||
|
||||
const metadata = this.extractMetadata(body)
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
updates.metadata = metadata
|
||||
updates.merge = true
|
||||
}
|
||||
|
||||
await this.context.brain.update(updates)
|
||||
|
||||
return {
|
||||
status: 204,
|
||||
headers: { 'OData-Version': '4.0' },
|
||||
body: null
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteEntity(
|
||||
_request: ODataRequest,
|
||||
path: string
|
||||
): Promise<ODataResponse> {
|
||||
if (!this.context) {
|
||||
return this.errorResponse(500, 'Integration not initialized')
|
||||
}
|
||||
|
||||
const idMatch = path.match(/^\/Entities\('([^']+)'\)/)
|
||||
if (!idMatch) {
|
||||
return this.errorResponse(400, 'Invalid entity ID')
|
||||
}
|
||||
|
||||
await this.context.brain.delete(idMatch[1])
|
||||
|
||||
return {
|
||||
status: 204,
|
||||
headers: { 'OData-Version': '4.0' },
|
||||
body: null
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRelationships(
|
||||
request: ODataRequest,
|
||||
path: string
|
||||
): Promise<ODataResponse> {
|
||||
// Single relationship: /Relationships('id')
|
||||
const idMatch = path.match(/^\/Relationships\('([^']+)'\)/)
|
||||
if (idMatch) {
|
||||
return this.getRelationship(idMatch[1])
|
||||
}
|
||||
|
||||
// Collection
|
||||
return this.listRelationships(request)
|
||||
}
|
||||
|
||||
private async listRelationships(
|
||||
request: ODataRequest
|
||||
): Promise<ODataResponse> {
|
||||
const options = parseODataQuery(request.query)
|
||||
|
||||
const pageSize = Math.min(
|
||||
options.top ?? this.odataConfig.defaultPageSize,
|
||||
this.odataConfig.maxPageSize
|
||||
)
|
||||
|
||||
const relations = await this.queryRelations({
|
||||
limit: pageSize,
|
||||
offset: options.skip
|
||||
})
|
||||
|
||||
let rows = this.exporter.relationsToRows(relations)
|
||||
|
||||
// Apply $filter
|
||||
if (options.filter) {
|
||||
rows = applyFilter(rows, options.filter) as any
|
||||
}
|
||||
|
||||
// Apply $select
|
||||
if (options.select && options.select.length > 0) {
|
||||
rows = applySelect(rows, options.select) as any
|
||||
}
|
||||
|
||||
// Apply $orderby
|
||||
if (options.orderBy && options.orderBy.length > 0) {
|
||||
rows = applyOrderBy(rows, options.orderBy)
|
||||
}
|
||||
|
||||
return this.jsonResponse({
|
||||
'@odata.context': `${this.basePath}/$metadata#Relationships`,
|
||||
value: rows
|
||||
})
|
||||
}
|
||||
|
||||
private async getRelationship(id: string): Promise<ODataResponse> {
|
||||
const relations = await this.queryRelations({ limit: 1000 })
|
||||
const relation = relations.find((r) => r.id === id)
|
||||
|
||||
if (!relation) {
|
||||
return this.errorResponse(404, `Relationship '${id}' not found`)
|
||||
}
|
||||
|
||||
const row = this.exporter.relationToRow(relation)
|
||||
|
||||
return this.jsonResponse({
|
||||
'@odata.context': `${this.basePath}/$metadata#Relationships/$entity`,
|
||||
...row
|
||||
})
|
||||
}
|
||||
|
||||
private async createRelationship(
|
||||
request: ODataRequest
|
||||
): Promise<ODataResponse> {
|
||||
if (!this.context) {
|
||||
return this.errorResponse(500, 'Integration not initialized')
|
||||
}
|
||||
|
||||
const body = request.body
|
||||
if (!body || !body.FromId || !body.ToId || !body.Type) {
|
||||
return this.errorResponse(
|
||||
400,
|
||||
'Missing required fields: FromId, ToId, Type'
|
||||
)
|
||||
}
|
||||
|
||||
const relation = await this.context.brain.relate({
|
||||
from: body.FromId,
|
||||
to: body.ToId,
|
||||
type: body.Type,
|
||||
weight: body.Weight,
|
||||
confidence: body.Confidence,
|
||||
metadata: body.Metadata ? JSON.parse(body.Metadata) : undefined,
|
||||
service: body.Service
|
||||
})
|
||||
|
||||
const row = this.exporter.relationToRow(relation)
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'OData-Version': '4.0',
|
||||
'Location': `${this.basePath}/Relationships('${relation.id}')`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'@odata.context': `${this.basePath}/$metadata#Relationships/$entity`,
|
||||
...row
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteRelationship(
|
||||
_request: ODataRequest,
|
||||
path: string
|
||||
): Promise<ODataResponse> {
|
||||
if (!this.context) {
|
||||
return this.errorResponse(500, 'Integration not initialized')
|
||||
}
|
||||
|
||||
const idMatch = path.match(/^\/Relationships\('([^']+)'\)/)
|
||||
if (!idMatch) {
|
||||
return this.errorResponse(400, 'Invalid relationship ID')
|
||||
}
|
||||
|
||||
await this.context.brain.unrelate(idMatch[1])
|
||||
|
||||
return {
|
||||
status: 204,
|
||||
headers: { 'OData-Version': '4.0' },
|
||||
body: null
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
private jsonResponse(data: any): ODataResponse {
|
||||
return {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'OData-Version': '4.0'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
}
|
||||
}
|
||||
|
||||
private errorResponse(status: number, message: string): ODataResponse {
|
||||
return {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'OData-Version': '4.0'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
code: String(status),
|
||||
message
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private extractMetadata(body: Record<string, any>): Record<string, any> {
|
||||
const metadata: Record<string, any> = {}
|
||||
const prefix = 'Metadata_'
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (key.startsWith(prefix)) {
|
||||
const metaKey = key.slice(prefix.length)
|
||||
metadata[metaKey] = value
|
||||
}
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
}
|
||||
650
src/integrations/odata/ODataQueryParser.ts
Normal file
650
src/integrations/odata/ODataQueryParser.ts
Normal file
|
|
@ -0,0 +1,650 @@
|
|||
/**
|
||||
* OData Query Parser
|
||||
*
|
||||
* Lightweight parser for OData query parameters without heavy dependencies.
|
||||
* Supports $filter, $select, $orderby, $top, $skip, $count, $search, $expand.
|
||||
*/
|
||||
|
||||
import { ODataQueryOptions } from '../core/types.js'
|
||||
|
||||
/**
|
||||
* Parse OData filter expression to a structured filter object
|
||||
*
|
||||
* Supports:
|
||||
* - eq, ne, gt, ge, lt, le comparisons
|
||||
* - and, or logical operators
|
||||
* - contains(), startswith(), endswith() string functions
|
||||
* - Parentheses for grouping
|
||||
*
|
||||
* @param filter OData $filter string
|
||||
* @returns Parsed filter object for query execution
|
||||
*/
|
||||
export function parseFilter(filter: string): any {
|
||||
if (!filter || filter.trim() === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Tokenize
|
||||
const tokens = tokenize(filter)
|
||||
|
||||
// Parse expression
|
||||
return parseExpression(tokens, 0).result
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse OData $orderby specification
|
||||
*/
|
||||
export function parseOrderBy(
|
||||
orderby: string
|
||||
): Array<{ field: string; direction: 'asc' | 'desc' }> {
|
||||
if (!orderby || orderby.trim() === '') {
|
||||
return []
|
||||
}
|
||||
|
||||
return orderby.split(',').map((part) => {
|
||||
const trimmed = part.trim()
|
||||
const parts = trimmed.split(/\s+/)
|
||||
return {
|
||||
field: parts[0],
|
||||
direction: (parts[1]?.toLowerCase() as 'asc' | 'desc') || 'asc'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse OData $select specification
|
||||
*/
|
||||
export function parseSelect(select: string): string[] {
|
||||
if (!select || select.trim() === '') {
|
||||
return []
|
||||
}
|
||||
|
||||
return select.split(',').map((s) => s.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse OData $expand specification
|
||||
*/
|
||||
export function parseExpand(expand: string): string[] {
|
||||
if (!expand || expand.trim() === '') {
|
||||
return []
|
||||
}
|
||||
|
||||
return expand.split(',').map((s) => s.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse full OData query string to options object
|
||||
*/
|
||||
export function parseODataQuery(queryString: string): ODataQueryOptions {
|
||||
const params = new URLSearchParams(queryString)
|
||||
const options: ODataQueryOptions = {}
|
||||
|
||||
// $filter
|
||||
const filter = params.get('$filter')
|
||||
if (filter) {
|
||||
options.filter = filter
|
||||
}
|
||||
|
||||
// $select
|
||||
const select = params.get('$select')
|
||||
if (select) {
|
||||
options.select = parseSelect(select)
|
||||
}
|
||||
|
||||
// $orderby
|
||||
const orderby = params.get('$orderby')
|
||||
if (orderby) {
|
||||
options.orderBy = parseOrderBy(orderby)
|
||||
}
|
||||
|
||||
// $top
|
||||
const top = params.get('$top')
|
||||
if (top) {
|
||||
options.top = parseInt(top, 10)
|
||||
}
|
||||
|
||||
// $skip
|
||||
const skip = params.get('$skip')
|
||||
if (skip) {
|
||||
options.skip = parseInt(skip, 10)
|
||||
}
|
||||
|
||||
// $expand
|
||||
const expand = params.get('$expand')
|
||||
if (expand) {
|
||||
options.expand = parseExpand(expand)
|
||||
}
|
||||
|
||||
// $count
|
||||
const count = params.get('$count')
|
||||
if (count === 'true') {
|
||||
options.count = true
|
||||
}
|
||||
|
||||
// $search
|
||||
const search = params.get('$search')
|
||||
if (search) {
|
||||
options.search = search
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OData options to Brainy FindParams
|
||||
*/
|
||||
export function odataToFindParams(options: ODataQueryOptions): any {
|
||||
const findParams: any = {}
|
||||
|
||||
// Top/Skip -> Limit/Offset
|
||||
if (options.top !== undefined) {
|
||||
findParams.limit = options.top
|
||||
}
|
||||
if (options.skip !== undefined) {
|
||||
findParams.offset = options.skip
|
||||
}
|
||||
|
||||
// OrderBy
|
||||
if (options.orderBy && options.orderBy.length > 0) {
|
||||
findParams.orderBy = options.orderBy[0].field
|
||||
findParams.order = options.orderBy[0].direction
|
||||
}
|
||||
|
||||
// Search -> Query
|
||||
if (options.search) {
|
||||
findParams.query = options.search
|
||||
}
|
||||
|
||||
// Filter -> Where (simplified)
|
||||
if (options.filter) {
|
||||
const parsed = parseFilter(options.filter)
|
||||
if (parsed) {
|
||||
findParams.where = filterToWhere(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
return findParams
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply OData filter to an array of entities
|
||||
*/
|
||||
export function applyFilter<T extends Record<string, any>>(
|
||||
entities: T[],
|
||||
filter: string
|
||||
): T[] {
|
||||
if (!filter) return entities
|
||||
|
||||
const parsed = parseFilter(filter)
|
||||
if (!parsed) return entities
|
||||
|
||||
return entities.filter((entity) => evaluateFilter(entity, parsed))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply OData select to transform entities
|
||||
*/
|
||||
export function applySelect<T extends Record<string, any>>(
|
||||
entities: T[],
|
||||
select: string[]
|
||||
): Partial<T>[] {
|
||||
if (!select || select.length === 0) return entities
|
||||
|
||||
return entities.map((entity) => {
|
||||
const result: Partial<T> = {}
|
||||
for (const field of select) {
|
||||
if (field in entity) {
|
||||
result[field as keyof T] = entity[field]
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply OData orderby to sort entities
|
||||
*/
|
||||
export function applyOrderBy<T extends Record<string, any>>(
|
||||
entities: T[],
|
||||
orderBy: Array<{ field: string; direction: 'asc' | 'desc' }>
|
||||
): T[] {
|
||||
if (!orderBy || orderBy.length === 0) return entities
|
||||
|
||||
return [...entities].sort((a, b) => {
|
||||
for (const { field, direction } of orderBy) {
|
||||
const aVal = getNestedValue(a, field)
|
||||
const bVal = getNestedValue(b, field)
|
||||
|
||||
let cmp = 0
|
||||
if (aVal === bVal) {
|
||||
cmp = 0
|
||||
} else if (aVal === null || aVal === undefined) {
|
||||
cmp = 1
|
||||
} else if (bVal === null || bVal === undefined) {
|
||||
cmp = -1
|
||||
} else if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
cmp = aVal.localeCompare(bVal)
|
||||
} else {
|
||||
cmp = aVal < bVal ? -1 : 1
|
||||
}
|
||||
|
||||
if (cmp !== 0) {
|
||||
return direction === 'desc' ? -cmp : cmp
|
||||
}
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply top/skip pagination
|
||||
*/
|
||||
export function applyPagination<T>(
|
||||
entities: T[],
|
||||
top?: number,
|
||||
skip?: number
|
||||
): T[] {
|
||||
let result = entities
|
||||
|
||||
if (skip !== undefined && skip > 0) {
|
||||
result = result.slice(skip)
|
||||
}
|
||||
|
||||
if (top !== undefined && top > 0) {
|
||||
result = result.slice(0, top)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
type Token = {
|
||||
type:
|
||||
| 'identifier'
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'null'
|
||||
| 'operator'
|
||||
| 'function'
|
||||
| 'lparen'
|
||||
| 'rparen'
|
||||
| 'comma'
|
||||
value: string
|
||||
}
|
||||
|
||||
function tokenize(input: string): Token[] {
|
||||
const tokens: Token[] = []
|
||||
let i = 0
|
||||
|
||||
while (i < input.length) {
|
||||
// Skip whitespace
|
||||
if (/\s/.test(input[i])) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// String literal
|
||||
if (input[i] === "'") {
|
||||
let str = ''
|
||||
i++
|
||||
while (i < input.length && input[i] !== "'") {
|
||||
if (input[i] === "'" && input[i + 1] === "'") {
|
||||
str += "'"
|
||||
i += 2
|
||||
} else {
|
||||
str += input[i]
|
||||
i++
|
||||
}
|
||||
}
|
||||
i++ // Skip closing quote
|
||||
tokens.push({ type: 'string', value: str })
|
||||
continue
|
||||
}
|
||||
|
||||
// Number
|
||||
if (/\d/.test(input[i]) || (input[i] === '-' && /\d/.test(input[i + 1]))) {
|
||||
let num = ''
|
||||
while (i < input.length && /[\d.\-]/.test(input[i])) {
|
||||
num += input[i]
|
||||
i++
|
||||
}
|
||||
tokens.push({ type: 'number', value: num })
|
||||
continue
|
||||
}
|
||||
|
||||
// Parentheses
|
||||
if (input[i] === '(') {
|
||||
tokens.push({ type: 'lparen', value: '(' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (input[i] === ')') {
|
||||
tokens.push({ type: 'rparen', value: ')' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Comma
|
||||
if (input[i] === ',') {
|
||||
tokens.push({ type: 'comma', value: ',' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Identifier or keyword
|
||||
if (/[a-zA-Z_]/.test(input[i])) {
|
||||
let ident = ''
|
||||
while (i < input.length && /[a-zA-Z0-9_./]/.test(input[i])) {
|
||||
ident += input[i]
|
||||
i++
|
||||
}
|
||||
|
||||
const lower = ident.toLowerCase()
|
||||
|
||||
// Operators
|
||||
if (['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'and', 'or', 'not'].includes(lower)) {
|
||||
tokens.push({ type: 'operator', value: lower })
|
||||
}
|
||||
// Boolean
|
||||
else if (lower === 'true' || lower === 'false') {
|
||||
tokens.push({ type: 'boolean', value: lower })
|
||||
}
|
||||
// Null
|
||||
else if (lower === 'null') {
|
||||
tokens.push({ type: 'null', value: 'null' })
|
||||
}
|
||||
// Functions
|
||||
else if (
|
||||
['contains', 'startswith', 'endswith', 'tolower', 'toupper', 'length', 'trim', 'substring'].includes(
|
||||
lower
|
||||
)
|
||||
) {
|
||||
tokens.push({ type: 'function', value: lower })
|
||||
}
|
||||
// Identifier (field name)
|
||||
else {
|
||||
tokens.push({ type: 'identifier', value: ident })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Unknown character, skip
|
||||
i++
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
function parseExpression(
|
||||
tokens: Token[],
|
||||
pos: number
|
||||
): { result: any; pos: number } {
|
||||
return parseOr(tokens, pos)
|
||||
}
|
||||
|
||||
function parseOr(
|
||||
tokens: Token[],
|
||||
pos: number
|
||||
): { result: any; pos: number } {
|
||||
let { result: left, pos: nextPos } = parseAnd(tokens, pos)
|
||||
|
||||
while (nextPos < tokens.length && tokens[nextPos]?.value === 'or') {
|
||||
nextPos++ // Skip 'or'
|
||||
const { result: right, pos: newPos } = parseAnd(tokens, nextPos)
|
||||
left = { or: [left, right] }
|
||||
nextPos = newPos
|
||||
}
|
||||
|
||||
return { result: left, pos: nextPos }
|
||||
}
|
||||
|
||||
function parseAnd(
|
||||
tokens: Token[],
|
||||
pos: number
|
||||
): { result: any; pos: number } {
|
||||
let { result: left, pos: nextPos } = parsePrimary(tokens, pos)
|
||||
|
||||
while (nextPos < tokens.length && tokens[nextPos]?.value === 'and') {
|
||||
nextPos++ // Skip 'and'
|
||||
const { result: right, pos: newPos } = parsePrimary(tokens, nextPos)
|
||||
left = { and: [left, right] }
|
||||
nextPos = newPos
|
||||
}
|
||||
|
||||
return { result: left, pos: nextPos }
|
||||
}
|
||||
|
||||
function parsePrimary(
|
||||
tokens: Token[],
|
||||
pos: number
|
||||
): { result: any; pos: number } {
|
||||
if (pos >= tokens.length) {
|
||||
return { result: null, pos }
|
||||
}
|
||||
|
||||
const token = tokens[pos]
|
||||
|
||||
// Parenthesized expression
|
||||
if (token.type === 'lparen') {
|
||||
const { result, pos: endPos } = parseExpression(tokens, pos + 1)
|
||||
// Skip rparen
|
||||
return { result, pos: endPos + 1 }
|
||||
}
|
||||
|
||||
// Function call
|
||||
if (token.type === 'function') {
|
||||
return parseFunction(tokens, pos)
|
||||
}
|
||||
|
||||
// Not operator
|
||||
if (token.value === 'not') {
|
||||
const { result, pos: endPos } = parsePrimary(tokens, pos + 1)
|
||||
return { result: { not: result }, pos: endPos }
|
||||
}
|
||||
|
||||
// Comparison: identifier operator value
|
||||
if (token.type === 'identifier') {
|
||||
const field = token.value
|
||||
pos++
|
||||
|
||||
if (pos >= tokens.length || tokens[pos].type !== 'operator') {
|
||||
return { result: { field, exists: true }, pos }
|
||||
}
|
||||
|
||||
const op = tokens[pos].value
|
||||
pos++
|
||||
|
||||
if (pos >= tokens.length) {
|
||||
return { result: { field, op, value: null }, pos }
|
||||
}
|
||||
|
||||
const valueToken = tokens[pos]
|
||||
let value: any
|
||||
|
||||
switch (valueToken.type) {
|
||||
case 'string':
|
||||
value = valueToken.value
|
||||
break
|
||||
case 'number':
|
||||
value = parseFloat(valueToken.value)
|
||||
break
|
||||
case 'boolean':
|
||||
value = valueToken.value === 'true'
|
||||
break
|
||||
case 'null':
|
||||
value = null
|
||||
break
|
||||
default:
|
||||
value = valueToken.value
|
||||
}
|
||||
|
||||
return { result: { field, op, value }, pos: pos + 1 }
|
||||
}
|
||||
|
||||
return { result: null, pos: pos + 1 }
|
||||
}
|
||||
|
||||
function parseFunction(
|
||||
tokens: Token[],
|
||||
pos: number
|
||||
): { result: any; pos: number } {
|
||||
const funcName = tokens[pos].value
|
||||
pos++ // Skip function name
|
||||
|
||||
if (tokens[pos]?.type !== 'lparen') {
|
||||
return { result: null, pos }
|
||||
}
|
||||
pos++ // Skip '('
|
||||
|
||||
const args: any[] = []
|
||||
|
||||
while (pos < tokens.length && tokens[pos]?.type !== 'rparen') {
|
||||
if (tokens[pos].type === 'comma') {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
|
||||
if (tokens[pos].type === 'identifier') {
|
||||
args.push({ field: tokens[pos].value })
|
||||
pos++
|
||||
} else if (tokens[pos].type === 'string') {
|
||||
args.push({ value: tokens[pos].value })
|
||||
pos++
|
||||
} else if (tokens[pos].type === 'number') {
|
||||
args.push({ value: parseFloat(tokens[pos].value) })
|
||||
pos++
|
||||
} else {
|
||||
pos++
|
||||
}
|
||||
}
|
||||
|
||||
pos++ // Skip ')'
|
||||
|
||||
return { result: { func: funcName, args }, pos }
|
||||
}
|
||||
|
||||
function evaluateFilter(entity: Record<string, any>, filter: any): boolean {
|
||||
if (!filter) return true
|
||||
|
||||
// Logical operators
|
||||
if (filter.and) {
|
||||
return filter.and.every((f: any) => evaluateFilter(entity, f))
|
||||
}
|
||||
if (filter.or) {
|
||||
return filter.or.some((f: any) => evaluateFilter(entity, f))
|
||||
}
|
||||
if (filter.not) {
|
||||
return !evaluateFilter(entity, filter.not)
|
||||
}
|
||||
|
||||
// Function
|
||||
if (filter.func) {
|
||||
return evaluateFunction(entity, filter.func, filter.args)
|
||||
}
|
||||
|
||||
// Comparison
|
||||
if (filter.field && filter.op) {
|
||||
const fieldValue = getNestedValue(entity, filter.field)
|
||||
return compareValues(fieldValue, filter.op, filter.value)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function evaluateFunction(
|
||||
entity: Record<string, any>,
|
||||
func: string,
|
||||
args: any[]
|
||||
): boolean {
|
||||
if (args.length < 2) return false
|
||||
|
||||
const fieldValue = args[0].field
|
||||
? String(getNestedValue(entity, args[0].field) ?? '')
|
||||
: ''
|
||||
const searchValue = args[1].value ?? ''
|
||||
|
||||
switch (func) {
|
||||
case 'contains':
|
||||
return fieldValue.toLowerCase().includes(searchValue.toLowerCase())
|
||||
case 'startswith':
|
||||
return fieldValue.toLowerCase().startsWith(searchValue.toLowerCase())
|
||||
case 'endswith':
|
||||
return fieldValue.toLowerCase().endsWith(searchValue.toLowerCase())
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function compareValues(fieldValue: any, op: string, filterValue: any): boolean {
|
||||
// Handle null comparisons
|
||||
if (filterValue === null) {
|
||||
switch (op) {
|
||||
case 'eq':
|
||||
return fieldValue === null || fieldValue === undefined
|
||||
case 'ne':
|
||||
return fieldValue !== null && fieldValue !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Null field value
|
||||
if (fieldValue === null || fieldValue === undefined) {
|
||||
return op === 'ne'
|
||||
}
|
||||
|
||||
// Type coercion for comparisons
|
||||
let fv = fieldValue
|
||||
let cv = filterValue
|
||||
|
||||
if (typeof filterValue === 'number' && typeof fieldValue === 'string') {
|
||||
fv = parseFloat(fieldValue)
|
||||
}
|
||||
if (typeof filterValue === 'string' && typeof fieldValue === 'number') {
|
||||
cv = parseFloat(filterValue)
|
||||
}
|
||||
|
||||
switch (op) {
|
||||
case 'eq':
|
||||
return fv === cv || (typeof fv === 'string' && fv.toLowerCase() === String(cv).toLowerCase())
|
||||
case 'ne':
|
||||
return fv !== cv
|
||||
case 'gt':
|
||||
return fv > cv
|
||||
case 'ge':
|
||||
return fv >= cv
|
||||
case 'lt':
|
||||
return fv < cv
|
||||
case 'le':
|
||||
return fv <= cv
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getNestedValue(obj: Record<string, any>, path: string): any {
|
||||
const parts = path.split(/[./]/)
|
||||
let value: any = obj
|
||||
|
||||
for (const part of parts) {
|
||||
if (value === null || value === undefined) return undefined
|
||||
value = value[part]
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function filterToWhere(filter: any): any {
|
||||
if (!filter) return {}
|
||||
|
||||
// Simple comparison
|
||||
if (filter.field && filter.op && filter.op === 'eq') {
|
||||
return { [filter.field]: filter.value }
|
||||
}
|
||||
|
||||
// For more complex filters, return as-is for storage adapter to handle
|
||||
return filter
|
||||
}
|
||||
24
src/integrations/odata/index.ts
Normal file
24
src/integrations/odata/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* OData Integration Module
|
||||
*
|
||||
* Provides OData 4.0 REST API for Excel, Power BI, and BI tools.
|
||||
*/
|
||||
|
||||
export { ODataIntegration, type ODataConfig } from './ODataIntegration.js'
|
||||
export {
|
||||
parseODataQuery,
|
||||
parseFilter,
|
||||
parseOrderBy,
|
||||
parseSelect,
|
||||
parseExpand,
|
||||
odataToFindParams,
|
||||
applyFilter,
|
||||
applySelect,
|
||||
applyOrderBy,
|
||||
applyPagination
|
||||
} from './ODataQueryParser.js'
|
||||
export {
|
||||
generateEdmx,
|
||||
generateMetadataJson,
|
||||
generateServiceDocument
|
||||
} from './EdmxGenerator.js'
|
||||
Loading…
Add table
Add a link
Reference in a new issue