diff --git a/docs/SQL-Triggers.md b/docs/SQL-Triggers.md new file mode 100644 index 0000000000..348292ea69 --- /dev/null +++ b/docs/SQL-Triggers.md @@ -0,0 +1,770 @@ +# SQL Triggers + +ArcadeDB supports database triggers that automatically execute SQL statements, JavaScript code, or Java classes in response to record events. Triggers enable you to implement business logic, data validation, audit trails, and automated workflows directly in your database. + +## Overview + +A **trigger** is a named database object that automatically executes when specific events occur on records of a particular type. Triggers can execute SQL statements, JavaScript code, or Java classes, giving you flexibility in how you implement your logic. + +### Key Features + +- **Event-driven**: Triggers fire automatically on CREATE, READ, UPDATE, or DELETE operations +- **Timing control**: Execute BEFORE or AFTER the event +- **Multi-language**: Choose between SQL statements, JavaScript code, or Java classes +- **High performance**: Java class triggers offer maximum performance with compiled code +- **Persistent**: Triggers are stored in the schema and survive database restarts +- **Type-specific**: Each trigger applies to a specific document/vertex/edge type + +## Syntax + +### Creating Triggers + +```sql +CREATE TRIGGER [IF NOT EXISTS] trigger_name + (BEFORE|AFTER) (CREATE|READ|UPDATE|DELETE) + ON [TYPE] type_name + EXECUTE (SQL|JAVASCRIPT|JAVA) 'code_or_class_name' +``` + +**Parameters:** +- `trigger_name` - Unique name for the trigger +- `IF NOT EXISTS` - Optional: skip creation if trigger already exists +- `BEFORE|AFTER` - When to execute (before or after the event) +- `CREATE|READ|UPDATE|DELETE` - Which event to respond to +- `type_name` - The type (document, vertex, or edge type) to monitor +- `SQL|JAVASCRIPT|JAVA` - Language/type of the trigger action +- `code_or_class_name` - SQL statement, JavaScript code, or fully qualified Java class name + +### Dropping Triggers + +```sql +DROP TRIGGER [IF EXISTS] trigger_name +``` + +## Trigger Events + +Triggers can respond to eight different combinations of timing and events: + +| Event | Description | +|-------|-------------| +| `BEFORE CREATE` | Before a new record is created | +| `AFTER CREATE` | After a new record is created | +| `BEFORE READ` | Before a record is read from database | +| `AFTER READ` | After a record is read from database | +| `BEFORE UPDATE` | Before a record is modified | +| `AFTER UPDATE` | After a record is modified | +| `BEFORE DELETE` | Before a record is deleted | +| `AFTER DELETE` | After a record is deleted | + +## SQL Triggers + +SQL triggers execute SQL statements. They have access to the current record through context variables. + +### Context Variables + +- `$record` or `record` - The current record being operated on + +### Example: Audit Trail + +Create an audit log that tracks all user creations: + +```sql +-- Create the audit log type +CREATE DOCUMENT TYPE AuditLog + +-- Create trigger to log user creations +CREATE TRIGGER user_audit AFTER CREATE ON TYPE User +EXECUTE SQL 'INSERT INTO AuditLog SET action = "user_created", + userName = $record.name, + timestamp = sysdate()' +``` + +Now every time a User is created, an entry is automatically added to the AuditLog: + +```sql +-- Create a user +INSERT INTO User SET name = 'Alice', email = 'alice@example.com' + +-- Check the audit log +SELECT * FROM AuditLog +-- Returns: {action: "user_created", userName: "Alice", timestamp: ...} +``` + +### Example: Auto-increment Counter + +Automatically set a sequence number on new documents: + +```sql +-- Create a counter type +CREATE DOCUMENT TYPE Counter +INSERT INTO Counter SET name = 'order_sequence', value = 1000 + +-- Create trigger to auto-increment order numbers +CREATE TRIGGER order_number BEFORE CREATE ON TYPE Order +EXECUTE SQL 'UPDATE Counter SET value = value + 1 + WHERE name = "order_sequence"; + UPDATE $record SET orderNumber = + (SELECT value FROM Counter WHERE name = "order_sequence")' +``` + +### Example: Cascade Updates + +Update related records when the parent changes: + +```sql +-- Update all orders when customer email changes +CREATE TRIGGER customer_email_update AFTER UPDATE ON TYPE Customer +EXECUTE SQL 'UPDATE Order SET customerEmail = $record.email + WHERE customerId = $record.@rid' +``` + +### Example: Data Validation + +Enforce business rules using BEFORE triggers: + +```sql +-- Ensure product prices are positive +CREATE TRIGGER validate_price BEFORE CREATE ON TYPE Product +EXECUTE SQL 'SELECT FROM Product WHERE @this = $record AND price > 0' +-- If this SELECT statement returns no results, the trigger fails and the operation is aborted. This provides a concise way to enforce data validation rules. +``` + +## JavaScript Triggers + +JavaScript triggers offer more flexibility and can implement complex logic with conditional statements, loops, and calculations. + +### Context Variables + +- `record` or `$record` - The current record being operated on +- `database` - The database instance + +### Return Value + +JavaScript triggers can return `false` to abort the operation (for BEFORE triggers only). + +### Example: Data Validation + +Validate email format before creating users: + +```sql +CREATE TRIGGER validate_email BEFORE CREATE ON TYPE User +EXECUTE JAVASCRIPT 'if (!record.email || !record.email.includes("@")) { + throw new Error("Invalid email address"); +}' +``` + +### Example: Auto-populate Fields + +Automatically set timestamps and computed fields: + +```sql +CREATE TRIGGER user_defaults BEFORE CREATE ON TYPE User +EXECUTE JAVASCRIPT ' + // Set creation timestamp + record.createdAt = new Date(); + + // Generate username from email if not provided + if (!record.username && record.email) { + record.username = record.email.split("@")[0]; + } + + // Set default role + if (!record.role) { + record.role = "user"; + } +' +``` + +### Example: Complex Business Logic + +Implement discount rules based on order total: + +```sql +CREATE TRIGGER calculate_discount BEFORE CREATE ON TYPE Order +EXECUTE JAVASCRIPT ' + var total = record.total || 0; + var discount = 0; + + // Apply discount based on order total + if (total > 1000) { + discount = 0.15; // 15% discount + } else if (total > 500) { + discount = 0.10; // 10% discount + } else if (total > 100) { + discount = 0.05; // 5% discount + } + + record.discountPercent = discount * 100; + record.discountAmount = total * discount; + record.finalTotal = total - (total * discount); +' +``` + +### Example: Conditional Abort + +Prevent operations based on business rules: + +```sql +CREATE TRIGGER prevent_weekend_orders BEFORE CREATE ON TYPE Order +EXECUTE JAVASCRIPT ' + var day = new Date().getDay(); + if (day === 0 || day === 6) { + throw new Error("Orders cannot be placed on weekends"); + } +' +``` + +### Example: Audit with Details + +Create detailed audit logs with JavaScript: + +```sql +CREATE TRIGGER audit_update AFTER UPDATE ON TYPE Product +EXECUTE JAVASCRIPT ' + database.command("sql", + "INSERT INTO AuditLog SET action = ?, productId = ?, productName = ?, timestamp = sysdate()", + "product_updated", + record["@rid"], + record.name + ); +' +``` + +## Java Triggers + +Java triggers offer maximum performance by executing compiled Java code. They require implementing the `JavaTrigger` interface and must be available in the classpath. + +### Creating a Java Trigger Class + +First, create a Java class that implements the `com.arcadedb.schema.trigger.JavaTrigger` interface: + +```java +package com.example.triggers; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Document; +import com.arcadedb.database.Record; +import com.arcadedb.schema.trigger.JavaTrigger; + +public class EmailValidationTrigger implements JavaTrigger { + + @Override + public boolean execute(Database database, Record record, Record oldRecord) throws Exception { + if (record instanceof Document) { + Document doc = (Document) record; + String email = doc.getString("email"); + + if (email == null || !email.contains("@")) { + throw new IllegalArgumentException("Invalid email address"); + } + } + return true; // Continue with the operation + } +} +``` + +### JavaTrigger Interface + +```java +public interface JavaTrigger { + /** + * Execute the trigger logic. + * + * @param database The database instance + * @param record The current record being operated on + * @param oldRecord The original record (for UPDATE operations), null otherwise + * @return true to continue the operation, false to abort (BEFORE triggers only) + * @throws Exception to abort the operation with an error message + */ + boolean execute(Database database, Record record, Record oldRecord) throws Exception; +} +``` + +### Registering Java Triggers + +Once your class is compiled and in the classpath, register it using SQL: + +```sql +CREATE TRIGGER validate_email BEFORE CREATE ON TYPE User +EXECUTE JAVA 'com.example.triggers.EmailValidationTrigger' +``` + +### Example: Simple Flag Setter + +Set a flag to indicate processing: + +```java +package com.example.triggers; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Document; +import com.arcadedb.database.Record; +import com.arcadedb.schema.trigger.JavaTrigger; + +public class ProcessedFlagTrigger implements JavaTrigger { + + @Override + public boolean execute(Database database, Record record, Record oldRecord) { + if (record instanceof Document) { + ((Document) record).modify().set("processed", true); + } + return true; + } +} +``` + +Register it: + +```sql +CREATE TRIGGER mark_processed BEFORE CREATE ON TYPE Order +EXECUTE JAVA 'com.example.triggers.ProcessedFlagTrigger' +``` + +### Example: Data Validation + +Validate complex business rules with full Java capabilities: + +```java +package com.example.triggers; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Document; +import com.arcadedb.database.Record; +import com.arcadedb.schema.trigger.JavaTrigger; +import java.math.BigDecimal; + +public class PriceValidationTrigger implements JavaTrigger { + + private static final BigDecimal MIN_PRICE = new BigDecimal("0.01"); + private static final BigDecimal MAX_PRICE = new BigDecimal("999999.99"); + + @Override + public boolean execute(Database database, Record record, Record oldRecord) throws Exception { + if (record instanceof Document) { + Document doc = (Document) record; + BigDecimal price = doc.get("price"); + + if (price == null) { + throw new IllegalArgumentException("Price is required"); + } + + if (price.compareTo(MIN_PRICE) < 0) { + throw new IllegalArgumentException("Price must be at least " + MIN_PRICE); + } + + if (price.compareTo(MAX_PRICE) > 0) { + throw new IllegalArgumentException("Price cannot exceed " + MAX_PRICE); + } + } + return true; + } +} +``` + +### Example: Abort Operation + +Return `false` to silently abort the operation: + +```java +package com.example.triggers; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Document; +import com.arcadedb.database.Record; +import com.arcadedb.schema.trigger.JavaTrigger; + +public class WorkingHoursOnlyTrigger implements JavaTrigger { + + @Override + public boolean execute(Database database, Record record, Record oldRecord) { + java.time.LocalTime now = java.time.LocalTime.now(); + java.time.LocalTime start = java.time.LocalTime.of(9, 0); + java.time.LocalTime end = java.time.LocalTime.of(17, 0); + + // Abort if outside working hours + return !now.isBefore(start) && !now.isAfter(end); + } +} +``` + +### Example: Database Queries + +Execute queries within the trigger: + +```java +package com.example.triggers; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Document; +import com.arcadedb.database.Record; +import com.arcadedb.query.sql.executor.ResultSet; +import com.arcadedb.schema.trigger.JavaTrigger; + +public class StockCheckTrigger implements JavaTrigger { + + @Override + public boolean execute(Database database, Record record, Record oldRecord) throws Exception { + if (record instanceof Document) { + Document doc = (Document) record; + String productId = doc.getString("productId"); + Integer quantity = doc.getInteger("quantity"); + + ResultSet result = database.query("sql", + "SELECT stock FROM Product WHERE @rid = ?", productId); + + if (result.hasNext()) { + Document product = result.next().toElement().asDocument(); + Integer stock = product.getInteger("stock"); + + if (stock < quantity) { + throw new IllegalStateException( + "Insufficient stock. Available: " + stock + ", Requested: " + quantity); + } + } + } + return true; + } +} +``` + +### Example: Modify Related Records + +Update related records in the same transaction: + +```java +package com.example.triggers; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Document; +import com.arcadedb.database.Record; +import com.arcadedb.schema.trigger.JavaTrigger; + +public class UpdateInventoryTrigger implements JavaTrigger { + + @Override + public boolean execute(Database database, Record record, Record oldRecord) { + if (record instanceof Document) { + Document orderItem = (Document) record; + String productId = orderItem.getString("productId"); + Integer quantity = orderItem.getInteger("quantity"); + + // Decrement stock + database.command("sql", + "UPDATE Product SET stock = stock - ? WHERE @rid = ?", + quantity, productId); + } + return true; + } +} +``` + +### Java Trigger Advantages + +1. **Performance**: Compiled code executes faster than interpreted JavaScript +2. **Type Safety**: Compile-time type checking prevents runtime errors +3. **Full Java Ecosystem**: Access to all Java libraries and frameworks +4. **IDE Support**: Code completion, refactoring, and debugging +5. **Testability**: Unit test your triggers like any Java class +6. **Reusability**: Share trigger code across projects + +### Java Trigger Considerations + +1. **Classpath**: Trigger classes must be in the ArcadeDB classpath at runtime +2. **Deployment**: Requires redeployment when trigger logic changes +3. **Error Handling**: Exceptions abort the operation and rollback the transaction +4. **Thread Safety**: Trigger instances may be reused across threads; ensure thread-safety +5. **No State**: Avoid instance variables; triggers should be stateless + +## Use Cases + +### 1. Audit Trails + +Track who changed what and when: + +```sql +CREATE TRIGGER audit_all AFTER UPDATE ON TYPE ImportantData +EXECUTE SQL 'INSERT INTO AuditLog SET + tableName = "ImportantData", + recordId = $record.@rid, + modifiedAt = sysdate()' +``` + +### 2. Data Integrity + +Ensure referential integrity and business rules: + +```sql +CREATE TRIGGER check_inventory BEFORE CREATE ON TYPE OrderItem +EXECUTE JAVASCRIPT ' + var result = database.query("sql", + "SELECT stock FROM Product WHERE @rid = ?", + record.productId + ); + + if (result.hasNext()) { + var product = result.next(); + if (product.stock < record.quantity) { + throw new Error("Insufficient stock"); + } + } +' +``` + +### 3. Denormalization + +Maintain computed or cached values: + +```sql +CREATE TRIGGER update_order_total AFTER CREATE ON TYPE OrderItem +EXECUTE SQL 'UPDATE Order SET + totalAmount = (SELECT sum(price * quantity) FROM OrderItem + WHERE orderId = $record.orderId) + WHERE @rid = $record.orderId' +``` + +### 4. Notifications + +Send alerts or trigger external processes: + +```sql +CREATE TRIGGER low_stock_alert AFTER UPDATE ON TYPE Product +EXECUTE JAVASCRIPT ' + if (record.stock < 10) { + database.command("sql", + "INSERT INTO Notification SET type = ?, productId = ?, message = ?", + "low_stock", + record["@rid"], + "Product stock low: " + record.stock + ); + } +' +``` + +### 5. Workflow Automation + +Automatically progress through workflow states: + +```sql +CREATE TRIGGER order_workflow AFTER UPDATE ON TYPE Order +EXECUTE JAVASCRIPT ' + // Auto-approve small orders + if (record.status === "pending" && record.total < 100) { + record.status = "approved"; + record.approvedAt = new Date(); + } +' +``` + +## Best Practices + +### 1. Keep Triggers Simple + +Triggers execute synchronously and can impact performance. Keep logic simple and fast: + +```sql +-- Good: Simple, fast operation +CREATE TRIGGER set_timestamp BEFORE CREATE ON TYPE Document +EXECUTE JAVASCRIPT 'record.createdAt = new Date();' + +-- Avoid: Complex calculations that could be done elsewhere +``` + +### 2. Use Meaningful Names + +Name triggers clearly to indicate their purpose: + +```sql +-- Good naming +CREATE TRIGGER audit_user_creation AFTER CREATE ON TYPE User ... +CREATE TRIGGER validate_email BEFORE CREATE ON TYPE User ... + +-- Poor naming +CREATE TRIGGER trigger1 AFTER CREATE ON TYPE User ... +``` + +### 3. Handle Errors Gracefully + +In JavaScript triggers, throw descriptive errors: + +```sql +CREATE TRIGGER validate_age BEFORE CREATE ON TYPE User +EXECUTE JAVASCRIPT ' + if (!record.age || record.age < 18) { + throw new Error("User must be at least 18 years old"); + } +' +``` + +### 4. Avoid Infinite Loops + +Be careful not to create circular trigger dependencies: + +```sql +-- DANGER: This could create an infinite loop if not careful +-- Trigger A updates Type B, which triggers B updates Type A, etc. +``` + +### 5. Test Thoroughly + +Test triggers with various scenarios: + +- Normal operations +- Edge cases (null values, empty strings, etc.) +- Error conditions +- Performance with large datasets + +## Managing Triggers + +### List All Triggers + +```java +// Java API +Trigger[] triggers = database.getSchema().getTriggers(); +for (Trigger trigger : triggers) { + System.out.println(trigger.getName() + " on " + trigger.getTypeName()); +} +``` + +### Check if Trigger Exists + +```java +boolean exists = database.getSchema().existsTrigger("trigger_name"); +``` + +### Get Triggers for a Type + +```java +Trigger[] triggers = database.getSchema().getTriggersForType("User"); +``` + +### Remove a Trigger + +```sql +DROP TRIGGER user_audit +``` + +## Limitations + +1. **Synchronous Execution**: Triggers execute synchronously within the transaction. Long-running triggers can impact performance. + +2. **Type Matching**: Triggers match the exact type name. Polymorphic matching (inheriting triggers from parent types) is not currently supported. + +3. **Order of Execution**: When multiple triggers exist for the same event on the same type, they execute in alphabetical order by trigger name. + +4. **BEFORE READ Limitation**: BEFORE READ triggers receive only the RID and must load the record, which can cause a double-read. + +5. **Transaction Context**: Triggers execute within the same transaction as the triggering operation. If a trigger fails, the entire transaction rolls back. + +6. **JavaScript Sandboxing**: JavaScript triggers run in a sandboxed environment with limited access to Java packages for security. + +## Performance Considerations + +### Benchmark Results + +Performance tests measuring trigger execution overhead on document creation with identical operations (100,000 iterations, Java 21, macOS). All triggers perform the same operation: `INSERT INTO AuditLog SET triggered = true`. + +| Trigger Type | Avg Time (µs) | Overhead (µs) | Overhead (%) | Relative Performance | +|--------------|---------------|---------------|--------------|---------------------| +| No Trigger (Baseline) | 95 | — | — | — | +| **Java Trigger** | **147** | **+52** | **+54.7%** | **Fastest trigger** | +| SQL Trigger | 150 | +55 | +57.9% | 2% slower than Java | +| JavaScript Trigger | 187 | +92 | +96.8% | 27% slower than Java | + +**Key Findings:** + +1. **Java and SQL triggers have nearly identical performance**: Only 2% difference (147 vs 150 µs), both execute compiled code paths efficiently. + +2. **JavaScript triggers are ~27% slower**: GraalVM JavaScript execution adds noticeable overhead compared to native execution. + +3. **All triggers add overhead**: Expect ~50-95% overhead depending on trigger type, which is acceptable for most use cases. + +4. **Trigger overhead is predictable**: The performance impact is consistent and can be factored into capacity planning. + +### Performance Recommendations + +- **Minimize Work**: Keep trigger code as lightweight as possible +- **Choose the Right Type**: + - Use **Java triggers** when you need type safety, IDE support, and debugging capabilities + - Use **SQL triggers** for database operations - performance is nearly identical to Java + - Use **JavaScript triggers** for dynamic logic where ~30% slower performance is acceptable +- **Avoid Complex Queries**: Heavy queries in triggers can slow down operations +- **Consider Batch Operations**: Triggers fire for each record, which can be expensive in bulk operations +- **Monitor Impact**: Test performance with realistic data volumes +- **Profile Your Workload**: Measure actual impact in your specific use case + +### Internal Optimizations + +ArcadeDB optimizes JavaScript trigger performance through engine pooling: + +- **Shared GraalVM Engine**: All JavaScript triggers share a single GraalVM Polyglot Engine instance across the entire database process, reducing memory overhead and initialization time +- **Lightweight Contexts**: Each trigger creates a lightweight execution context that reuses the shared engine +- **Lazy Initialization**: Engine and context creation is deferred until the trigger first executes +- **Automatic Resource Management**: Contexts are properly closed when triggers are removed, while the shared engine persists for the lifetime of the database process + +This architecture ensures that creating multiple JavaScript triggers does not linearly increase memory consumption or initialization overhead. + +### When to Use Each Type + +**Java Triggers** - Best for: +- Complex validation requiring type safety and compile-time checks +- Integration with existing Java libraries +- Code that benefits from IDE support and refactoring +- Unit testing requirements +- Team prefers strongly-typed languages + +**SQL Triggers** - Best for: +- Simple database operations (audit logs, denormalization) +- Prototyping and development (no compilation step) +- Deployment simplicity (embedded in schema) +- Operations that are primarily SQL-based +- **Performance-critical paths** (nearly identical performance to Java) + +**JavaScript Triggers** - Best for: +- Moderate complexity business logic +- Rapid development and iteration +- Dynamic validation rules that change frequently +- Scenarios where scripting flexibility outweighs performance +- Teams comfortable with JavaScript + +## Troubleshooting + +### Trigger Not Firing + +1. Verify the trigger exists: `SELECT FROM schema:triggers` +2. Check the type name matches exactly +3. Ensure the event type is correct (CREATE/READ/UPDATE/DELETE) +4. Verify trigger timing (BEFORE/AFTER) + +### JavaScript Errors + +Check logs for detailed error messages. Common issues: +- Syntax errors in JavaScript code +- Accessing undefined properties +- Type mismatches +- Security restrictions + +### Java Trigger Errors + +Common issues with Java triggers: +- ClassNotFoundException: Trigger class not in classpath +- NoSuchMethodException: Missing no-arg constructor +- ClassCastException: Class doesn't implement JavaTrigger interface +- NullPointerException: Check for null values in record properties +- Thread safety issues: Ensure triggers are stateless + +### Performance Issues + +- Profile which triggers are executing +- Consider disabling triggers temporarily for bulk operations +- Optimize trigger logic +- Move complex processing to application code + +## Examples Summary + +For more examples and real-world scenarios, see the test suite in `engine/src/test/java/com/arcadedb/query/sql/TriggerSQLTest.java`. + +## Next Steps + +- Learn about [Event Listeners](https://docs.arcadedb.com) for programmatic trigger alternatives +- Explore [SQL Commands](https://docs.arcadedb.com) for more database operations +- Review [JavaScript in ArcadeDB](https://docs.arcadedb.com) for advanced scripting diff --git a/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLLexer.g4 b/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLLexer.g4 index 069f77867a..afc9156193 100644 --- a/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLLexer.g4 +++ b/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLLexer.g4 @@ -202,6 +202,7 @@ FUNCTION: F U N C T I O N; GLOBAL: G L O B A L; PARAMETERS: P A R A M E T E R S; LANGUAGE: L A N G U A G E; +TRIGGER: T R I G G E R; FAIL: F A I L; FIX: F I X; SLEEP: S L E E P; diff --git a/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 b/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 index ee07564400..2d8d908cc0 100644 --- a/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 +++ b/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 @@ -93,6 +93,7 @@ statement | CREATE BUCKET createBucketBody # createBucketStmt | CREATE VERTEX createVertexBody # createVertexStmt | CREATE EDGE createEdgeBody # createEdgeStmt + | CREATE TRIGGER createTriggerBody # createTriggerStmt // DDL Statements - ALTER variants | ALTER TYPE alterTypeBody # alterTypeStmt @@ -105,6 +106,7 @@ statement | DROP PROPERTY dropPropertyBody # dropPropertyStmt | DROP INDEX dropIndexBody # dropIndexStmt | DROP BUCKET dropBucketBody # dropBucketStmt + | DROP TRIGGER dropTriggerBody # dropTriggerStmt // DDL Statements - TRUNCATE variants | TRUNCATE TYPE truncateTypeBody # truncateTypeStmt @@ -586,6 +588,46 @@ dropBucketBody : identifier (IF EXISTS)? ; +// ============================================================================ +// TRIGGER MANAGEMENT +// ============================================================================ + +/** + * CREATE TRIGGER statement + * Syntax: CREATE TRIGGER [IF NOT EXISTS] name (BEFORE|AFTER) (CREATE|READ|UPDATE|DELETE) + * ON [TYPE] typeName (EXECUTE SQL 'statement' | EXECUTE JAVASCRIPT 'code' | EXECUTE JAVA 'className') + */ +createTriggerBody + : (IF NOT EXISTS)? identifier + triggerTiming triggerEvent + ON TYPE? identifier + triggerAction + ; + +triggerTiming + : BEFORE + | AFTER + ; + +triggerEvent + : CREATE + | READ + | UPDATE + | DELETE + ; + +triggerAction + : EXECUTE identifier STRING_LITERAL + ; + +/** + * DROP TRIGGER statement + * Syntax: DROP TRIGGER [IF EXISTS] name + */ +dropTriggerBody + : (IF EXISTS)? identifier + ; + // ============================================================================ // DDL STATEMENTS - TRUNCATE // ============================================================================ diff --git a/engine/src/main/java/com/arcadedb/function/polyglot/PolyglotFunctionLibraryDefinition.java b/engine/src/main/java/com/arcadedb/function/polyglot/PolyglotFunctionLibraryDefinition.java index e471ec7689..4f7e08406b 100644 --- a/engine/src/main/java/com/arcadedb/function/polyglot/PolyglotFunctionLibraryDefinition.java +++ b/engine/src/main/java/com/arcadedb/function/polyglot/PolyglotFunctionLibraryDefinition.java @@ -20,7 +20,7 @@ import com.arcadedb.database.Database; import com.arcadedb.function.FunctionLibraryDefinition; import com.arcadedb.query.polyglot.GraalPolyglotEngine; -import org.graalvm.polyglot.Engine; +import com.arcadedb.query.polyglot.PolyglotEngineManager; import java.util.*; import java.util.concurrent.*; @@ -42,7 +42,8 @@ protected PolyglotFunctionLibraryDefinition(final Database database, final Strin this.libraryName = libraryName; this.language = language; this.allowedPackages = allowedPackages; - this.polyglotEngine = GraalPolyglotEngine.newBuilder(database, Engine.create()).setLanguage(language).setAllowedPackages(allowedPackages).build(); + this.polyglotEngine = GraalPolyglotEngine.newBuilder(database, PolyglotEngineManager.getInstance().getSharedEngine()) + .setLanguage(language).setAllowedPackages(allowedPackages).build(); } public PolyglotFunctionLibraryDefinition registerFunction(final T function) { @@ -51,7 +52,8 @@ public PolyglotFunctionLibraryDefinition registerFunction(final T function) { // REGISTER ALL THE FUNCTIONS UNDER THE NEW ENGINE INSTANCE this.polyglotEngine.close(); - this.polyglotEngine = GraalPolyglotEngine.newBuilder(database, Engine.create()).setLanguage(language).setAllowedPackages(allowedPackages).build(); + this.polyglotEngine = GraalPolyglotEngine.newBuilder(database, PolyglotEngineManager.getInstance().getSharedEngine()) + .setLanguage(language).setAllowedPackages(allowedPackages).build(); for (final T f : functions.values()) f.init(this); diff --git a/engine/src/main/java/com/arcadedb/query/polyglot/GraalPolyglotEngine.java b/engine/src/main/java/com/arcadedb/query/polyglot/GraalPolyglotEngine.java index 2e46a75e72..78f6e1495e 100644 --- a/engine/src/main/java/com/arcadedb/query/polyglot/GraalPolyglotEngine.java +++ b/engine/src/main/java/com/arcadedb/query/polyglot/GraalPolyglotEngine.java @@ -49,7 +49,8 @@ public class GraalPolyglotEngine implements AutoCloseable { static { try { - supportedLanguages = Engine.create().getLanguages().keySet(); + // Use the shared engine to discover supported languages + supportedLanguages = PolyglotEngineManager.getInstance().getSharedEngine().getLanguages().keySet(); } catch (Throwable e) { LogManager.instance().log(GraalPolyglotEngine.class, Level.SEVERE, "GraalVM Polyglot Engine: no languages found"); supportedLanguages = Collections.emptySet(); diff --git a/engine/src/main/java/com/arcadedb/query/polyglot/PolyglotEngineManager.java b/engine/src/main/java/com/arcadedb/query/polyglot/PolyglotEngineManager.java new file mode 100644 index 0000000000..cca3e8ab4c --- /dev/null +++ b/engine/src/main/java/com/arcadedb/query/polyglot/PolyglotEngineManager.java @@ -0,0 +1,81 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.polyglot; + +import com.arcadedb.log.LogManager; +import org.graalvm.polyglot.Engine; + +import java.util.logging.Level; + +/** + * Singleton manager for GraalVM Polyglot Engine instances. + * The Engine is a heavyweight object that should be shared across multiple Context instances + * for optimal performance. This manager provides a single shared Engine instance for the entire + * ArcadeDB process. + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class PolyglotEngineManager { + private static final PolyglotEngineManager INSTANCE = new PolyglotEngineManager(); + private volatile Engine sharedEngine; + + private PolyglotEngineManager() { + // Private constructor for singleton + } + + public static PolyglotEngineManager getInstance() { + return INSTANCE; + } + + /** + * Returns the shared GraalVM Engine instance. Creates it on first access (lazy initialization). + * The Engine is thread-safe and can be used concurrently by multiple contexts. + * + * @return the shared Engine instance + */ + public Engine getSharedEngine() { + if (sharedEngine == null) { + synchronized (this) { + if (sharedEngine == null) { + LogManager.instance() + .log(this, Level.FINE, "Creating shared GraalVM Polyglot Engine for ArcadeDB process"); + sharedEngine = Engine.create(); + } + } + } + return sharedEngine; + } + + /** + * Closes the shared Engine. This should only be called during application shutdown. + * After calling this method, subsequent calls to getSharedEngine() will create a new Engine. + */ + public synchronized void closeSharedEngine() { + if (sharedEngine != null) { + try { + LogManager.instance().log(this, Level.FINE, "Closing shared GraalVM Polyglot Engine"); + sharedEngine.close(); + } catch (final Exception e) { + LogManager.instance().log(this, Level.WARNING, "Error closing shared GraalVM Polyglot Engine", e); + } finally { + sharedEngine = null; + } + } + } +} diff --git a/engine/src/main/java/com/arcadedb/query/polyglot/PolyglotQueryEngine.java b/engine/src/main/java/com/arcadedb/query/polyglot/PolyglotQueryEngine.java index 6b695980df..6f07e61a5f 100644 --- a/engine/src/main/java/com/arcadedb/query/polyglot/PolyglotQueryEngine.java +++ b/engine/src/main/java/com/arcadedb/query/polyglot/PolyglotQueryEngine.java @@ -29,7 +29,6 @@ import com.arcadedb.query.sql.executor.InternalResultSet; import com.arcadedb.query.sql.executor.ResultInternal; import com.arcadedb.query.sql.executor.ResultSet; -import org.graalvm.polyglot.Engine; import org.graalvm.polyglot.Value; import java.util.*; @@ -88,8 +87,8 @@ protected PolyglotQueryEngine(final DatabaseInternal database, final String lang this.language = language; this.database = database; this.allowedPackages = allowedPackages; - this.polyglotEngine = GraalPolyglotEngine.newBuilder(database, Engine.create()).setLanguage(language) - .setAllowedPackages(allowedPackages).build(); + this.polyglotEngine = GraalPolyglotEngine.newBuilder(database, PolyglotEngineManager.getInstance().getSharedEngine()) + .setLanguage(language).setAllowedPackages(allowedPackages).build(); this.userCodeExecutorQueue = new ArrayBlockingQueue<>(10000); this.userCodeExecutor = new ThreadPoolExecutor(8, 8, 30, TimeUnit.SECONDS, userCodeExecutorQueue, new ThreadPoolExecutor.CallerRunsPolicy()); @@ -174,8 +173,8 @@ public QueryEngine registerFunctions(final String function) { @Override public QueryEngine unregisterFunctions() { - this.polyglotEngine = GraalPolyglotEngine.newBuilder(database, Engine.create()).setLanguage(language) - .setAllowedPackages(allowedPackages).build(); + this.polyglotEngine = GraalPolyglotEngine.newBuilder(database, PolyglotEngineManager.getInstance().getSharedEngine()) + .setLanguage(language).setAllowedPackages(allowedPackages).build(); return this; } diff --git a/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java b/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java index e627924407..323642474e 100644 --- a/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java +++ b/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java @@ -5595,6 +5595,90 @@ public DropBucketStatement visitDropBucketStmt(final SQLParser.DropBucketStmtCon return stmt; } + // TRIGGER MANAGEMENT + + /** + * Visit CREATE TRIGGER statement. + */ + @Override + public CreateTriggerStatement visitCreateTriggerStmt(final SQLParser.CreateTriggerStmtContext ctx) { + final CreateTriggerStatement stmt = new CreateTriggerStatement(-1); + final SQLParser.CreateTriggerBodyContext bodyCtx = ctx.createTriggerBody(); + + // IF NOT EXISTS flag + stmt.ifNotExists = bodyCtx.IF() != null && bodyCtx.NOT() != null && bodyCtx.EXISTS() != null; + + // Trigger name (first identifier) + stmt.name = (Identifier) visit(bodyCtx.identifier(0)); + + // Trigger timing (BEFORE or AFTER) + stmt.timing = (Identifier) visit(bodyCtx.triggerTiming()); + + // Trigger event (CREATE, READ, UPDATE, DELETE) + stmt.event = (Identifier) visit(bodyCtx.triggerEvent()); + + // Type name (second identifier - the one after ON) + stmt.typeName = (Identifier) visit(bodyCtx.identifier(1)); + + // Action type and code (SQL or JAVASCRIPT) + final SQLParser.TriggerActionContext actionCtx = bodyCtx.triggerAction(); + final Identifier actionTypeId = (Identifier) visit(actionCtx.identifier()); + stmt.actionType = actionTypeId; + + // Extract string literal and remove quotes + final String rawText = actionCtx.STRING_LITERAL().getText(); + stmt.actionCode = rawText.substring(1, rawText.length() - 1); + + return stmt; + } + + /** + * Visit DROP TRIGGER statement. + */ + @Override + public DropTriggerStatement visitDropTriggerStmt(final SQLParser.DropTriggerStmtContext ctx) { + final DropTriggerStatement stmt = new DropTriggerStatement(-1); + final SQLParser.DropTriggerBodyContext bodyCtx = ctx.dropTriggerBody(); + + // Trigger name + stmt.name = (Identifier) visit(bodyCtx.identifier()); + + // IF EXISTS + stmt.ifExists = bodyCtx.IF() != null && bodyCtx.EXISTS() != null; + + return stmt; + } + + /** + * Visit trigger timing (BEFORE or AFTER). + */ + @Override + public Identifier visitTriggerTiming(final SQLParser.TriggerTimingContext ctx) { + if (ctx.BEFORE() != null) { + return new Identifier("BEFORE"); + } else if (ctx.AFTER() != null) { + return new Identifier("AFTER"); + } + return null; + } + + /** + * Visit trigger event (CREATE, READ, UPDATE, DELETE). + */ + @Override + public Identifier visitTriggerEvent(final SQLParser.TriggerEventContext ctx) { + if (ctx.CREATE() != null) { + return new Identifier("CREATE"); + } else if (ctx.READ() != null) { + return new Identifier("READ"); + } else if (ctx.UPDATE() != null) { + return new Identifier("UPDATE"); + } else if (ctx.DELETE() != null) { + return new Identifier("DELETE"); + } + return null; + } + // DDL STATEMENTS - TRUNCATE /** diff --git a/engine/src/main/java/com/arcadedb/query/sql/parser/CreateTriggerStatement.java b/engine/src/main/java/com/arcadedb/query/sql/parser/CreateTriggerStatement.java new file mode 100644 index 0000000000..6ff3880d08 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/query/sql/parser/CreateTriggerStatement.java @@ -0,0 +1,165 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.sql.parser; + +import com.arcadedb.database.Database; +import com.arcadedb.exception.CommandExecutionException; +import com.arcadedb.exception.CommandSQLParsingException; +import com.arcadedb.query.sql.executor.CommandContext; +import com.arcadedb.query.sql.executor.InternalResultSet; +import com.arcadedb.query.sql.executor.ResultInternal; +import com.arcadedb.query.sql.executor.ResultSet; +import com.arcadedb.schema.Trigger; +import com.arcadedb.schema.TriggerImpl; + +/** + * SQL Statement for CREATE TRIGGER command. + * Syntax: CREATE TRIGGER [IF NOT EXISTS] name (BEFORE|AFTER) (CREATE|READ|UPDATE|DELETE) + * ON [TYPE] typeName (EXECUTE SQL 'statement' | EXECUTE JAVASCRIPT 'code' | EXECUTE JAVA 'className') + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class CreateTriggerStatement extends DDLStatement { + + public Identifier name; + public Identifier timing; // BEFORE or AFTER + public Identifier event; // CREATE, READ, UPDATE, DELETE + public Identifier typeName; + public Identifier actionType; // SQL, JAVASCRIPT, or JAVA + public String actionCode; + public boolean ifNotExists = false; + + public CreateTriggerStatement(final int id) { + super(id); + } + + @Override + public void validate() throws CommandSQLParsingException { + if (name == null || name.getStringValue() == null || name.getStringValue().trim().isEmpty()) { + throw new CommandSQLParsingException("Trigger name is required"); + } + + if (timing == null) { + throw new CommandSQLParsingException("Trigger timing (BEFORE/AFTER) is required"); + } + + final String timingStr = timing.getStringValue().toUpperCase(); + if (!timingStr.equals("BEFORE") && !timingStr.equals("AFTER")) { + throw new CommandSQLParsingException("Trigger timing must be BEFORE or AFTER"); + } + + if (event == null) { + throw new CommandSQLParsingException("Trigger event (CREATE/READ/UPDATE/DELETE) is required"); + } + + final String eventStr = event.getStringValue().toUpperCase(); + if (!eventStr.equals("CREATE") && !eventStr.equals("READ") && + !eventStr.equals("UPDATE") && !eventStr.equals("DELETE")) { + throw new CommandSQLParsingException("Trigger event must be CREATE, READ, UPDATE, or DELETE"); + } + + if (typeName == null || typeName.getStringValue() == null || typeName.getStringValue().trim().isEmpty()) { + throw new CommandSQLParsingException("Trigger type name is required"); + } + + if (actionType == null) { + throw new CommandSQLParsingException("Trigger action type (SQL/JAVASCRIPT/JAVA) is required"); + } + + final String actionTypeStr = actionType.getStringValue().toUpperCase(); + if (!actionTypeStr.equals("SQL") && !actionTypeStr.equals("JAVASCRIPT") && !actionTypeStr.equals("JAVA")) { + throw new CommandSQLParsingException("Trigger action type must be SQL, JAVASCRIPT, or JAVA"); + } + + if (actionCode == null || actionCode.trim().isEmpty()) { + throw new CommandSQLParsingException("Trigger action code is required"); + } + } + + @Override + public ResultSet executeDDL(final CommandContext context) { + final Database database = context.getDatabase(); + + // Validate inputs + validate(); + + // Check if trigger already exists + if (database.getSchema().existsTrigger(name.getStringValue())) { + if (ifNotExists) { + final InternalResultSet rs = new InternalResultSet(); + final ResultInternal result = new ResultInternal(context.getDatabase()); + result.setProperty("operation", "create trigger"); + result.setProperty("name", name.getStringValue()); + result.setProperty("created", false); + rs.add(result); + return rs; + } else { + throw new CommandExecutionException("Trigger '" + name.getStringValue() + "' already exists"); + } + } + + // Check if type exists + if (!database.getSchema().existsType(typeName.getStringValue())) { + throw new CommandExecutionException("Type '" + typeName.getStringValue() + "' does not exist"); + } + + // Parse enums + final Trigger.TriggerTiming triggerTiming = Trigger.TriggerTiming.valueOf(timing.getStringValue().toUpperCase()); + final Trigger.TriggerEvent triggerEvent = Trigger.TriggerEvent.valueOf(event.getStringValue().toUpperCase()); + final Trigger.ActionType triggerActionType = Trigger.ActionType.valueOf(actionType.getStringValue().toUpperCase()); + + // Create trigger + final Trigger trigger = new TriggerImpl( + name.getStringValue(), + triggerTiming, + triggerEvent, + typeName.getStringValue(), + triggerActionType, + actionCode + ); + + // Register trigger in schema + database.getSchema().createTrigger(trigger); + + // Return result + final InternalResultSet rs = new InternalResultSet(); + final ResultInternal result = new ResultInternal(context.getDatabase()); + result.setProperty("operation", "create trigger"); + result.setProperty("name", name.getStringValue()); + result.setProperty("timing", triggerTiming.name()); + result.setProperty("event", triggerEvent.name()); + result.setProperty("typeName", typeName.getStringValue()); + result.setProperty("actionType", triggerActionType.name()); + result.setProperty("created", true); + rs.add(result); + return rs; + } + + @Override + public String toString() { + return "CreateTriggerStatement{" + + "name=" + name + + ", timing=" + timing + + ", event=" + event + + ", typeName=" + typeName + + ", actionType=" + actionType + + ", ifNotExists=" + ifNotExists + + '}'; + } +} diff --git a/engine/src/main/java/com/arcadedb/query/sql/parser/DropTriggerStatement.java b/engine/src/main/java/com/arcadedb/query/sql/parser/DropTriggerStatement.java new file mode 100644 index 0000000000..b80e06fce9 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/query/sql/parser/DropTriggerStatement.java @@ -0,0 +1,111 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.sql.parser; + +import com.arcadedb.database.Database; +import com.arcadedb.exception.CommandExecutionException; +import com.arcadedb.query.sql.executor.CommandContext; +import com.arcadedb.query.sql.executor.InternalResultSet; +import com.arcadedb.query.sql.executor.ResultInternal; +import com.arcadedb.query.sql.executor.ResultSet; + +import java.util.Map; +import java.util.Objects; + +/** + * SQL Statement for DROP TRIGGER command. + * Syntax: DROP TRIGGER [IF EXISTS] name + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class DropTriggerStatement extends DDLStatement { + + public Identifier name; + public boolean ifExists = false; + + public DropTriggerStatement(final int id) { + super(id); + } + + @Override + public ResultSet executeDDL(final CommandContext context) { + final InternalResultSet rs = new InternalResultSet(); + final Database db = context.getDatabase(); + + if (!db.getSchema().existsTrigger(name.getValue()) && !ifExists) { + throw new CommandExecutionException("Trigger not found: " + name.getValue()); + } + + if (db.getSchema().existsTrigger(name.getValue())) { + db.getSchema().dropTrigger(name.getValue()); + + final ResultInternal result = new ResultInternal(); + result.setProperty("operation", "drop trigger"); + result.setProperty("triggerName", name.getValue()); + result.setProperty("dropped", true); + rs.add(result); + } else { + final ResultInternal result = new ResultInternal(); + result.setProperty("operation", "drop trigger"); + result.setProperty("triggerName", name.getValue()); + result.setProperty("dropped", false); + rs.add(result); + } + + return rs; + } + + @Override + public void toString(final Map params, final StringBuilder builder) { + builder.append("DROP TRIGGER "); + if (ifExists) { + builder.append("IF EXISTS "); + } + name.toString(params, builder); + } + + @Override + public DropTriggerStatement copy() { + final DropTriggerStatement result = new DropTriggerStatement(-1); + result.name = name == null ? null : name.copy(); + result.ifExists = ifExists; + return result; + } + + @Override + public boolean equals(final Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + final DropTriggerStatement that = (DropTriggerStatement) o; + + if (ifExists != that.ifExists) + return false; + return Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + int result = (ifExists ? 1 : 0); + result = 31 * result + (name != null ? name.hashCode() : 0); + return result; + } +} diff --git a/engine/src/main/java/com/arcadedb/schema/LocalSchema.java b/engine/src/main/java/com/arcadedb/schema/LocalSchema.java index 06a7e41faf..4cdbae582c 100644 --- a/engine/src/main/java/com/arcadedb/schema/LocalSchema.java +++ b/engine/src/main/java/com/arcadedb/schema/LocalSchema.java @@ -88,6 +88,8 @@ public class LocalSchema implements Schema { private Map bucketId2TypeMap = new HashMap<>(); private Map bucketId2InvolvedTypeMap = new HashMap<>(); protected final Map indexMap = new HashMap<>(); + protected final Map triggers = new HashMap<>(); + private final Map triggerAdapters = new HashMap<>(); private final String databasePath; private final File configurationFile; private final ComponentFactory componentFactory; @@ -496,6 +498,167 @@ public void dropIndex(final String indexName) { }); } + // TRIGGER MANAGEMENT + + @Override + public boolean existsTrigger(final String triggerName) { + return triggers.containsKey(triggerName); + } + + @Override + public Trigger getTrigger(final String triggerName) { + return triggers.get(triggerName); + } + + @Override + public Trigger[] getTriggers() { + return triggers.values().toArray(new Trigger[0]); + } + + @Override + public Trigger[] getTriggersForType(final String typeName) { + return triggers.values().stream() + .filter(t -> t.getTypeName().equals(typeName)) + .toArray(Trigger[]::new); + } + + @Override + public void createTrigger(final Trigger trigger) { + database.checkPermissionsOnDatabase(SecurityDatabaseUser.DATABASE_ACCESS.UPDATE_SCHEMA); + + recordFileChanges(() -> { + // Validate trigger does not already exist + if (triggers.containsKey(trigger.getName())) { + throw new SchemaException("Trigger '" + trigger.getName() + "' already exists"); + } + + // Validate type exists + if (!existsType(trigger.getTypeName())) { + throw new SchemaException("Type '" + trigger.getTypeName() + "' does not exist"); + } + + // Store trigger + triggers.put(trigger.getName(), trigger); + + // Register event listener + registerTriggerListener(trigger); + + return null; + }); + } + + @Override + public void dropTrigger(final String triggerName) { + database.checkPermissionsOnDatabase(SecurityDatabaseUser.DATABASE_ACCESS.UPDATE_SCHEMA); + + recordFileChanges(() -> { + final Trigger trigger = triggers.get(triggerName); + if (trigger == null) { + throw new SchemaException("Trigger '" + triggerName + "' does not exist"); + } + + // Unregister event listener + unregisterTriggerListener(triggerName); + + // Remove trigger + triggers.remove(triggerName); + + return null; + }); + } + + /** + * Register a trigger as an event listener on the appropriate type. + */ + private void registerTriggerListener(final Trigger trigger) { + final LocalDocumentType type = types.get(trigger.getTypeName()); + if (type == null) { + throw new SchemaException("Type '" + trigger.getTypeName() + "' not found"); + } + + // Create executor + final com.arcadedb.schema.trigger.TriggerExecutor executor; + if (trigger.getActionType() == Trigger.ActionType.SQL) { + executor = new com.arcadedb.schema.trigger.SQLTriggerExecutor(trigger.getName(), trigger.getActionCode()); + } else if (trigger.getActionType() == Trigger.ActionType.JAVASCRIPT) { + executor = new com.arcadedb.schema.trigger.ScriptTriggerExecutor(trigger.getName(), trigger.getActionCode()); + } else if (trigger.getActionType() == Trigger.ActionType.JAVA) { + executor = new com.arcadedb.schema.trigger.JavaClassTriggerExecutor(trigger.getName(), trigger.getActionCode()); + } else { + throw new SchemaException("Unknown trigger action type: " + trigger.getActionType()); + } + + // Create adapter + final com.arcadedb.schema.trigger.TriggerListenerAdapter adapter = + new com.arcadedb.schema.trigger.TriggerListenerAdapter(database, trigger, executor); + + // Register listener based on timing and event + final com.arcadedb.database.RecordEventsRegistry events = (com.arcadedb.database.RecordEventsRegistry) type.getEvents(); + switch (trigger.getTiming()) { + case BEFORE -> { + switch (trigger.getEvent()) { + case CREATE -> events.registerListener((com.arcadedb.event.BeforeRecordCreateListener) adapter); + case READ -> events.registerListener((com.arcadedb.event.BeforeRecordReadListener) adapter); + case UPDATE -> events.registerListener((com.arcadedb.event.BeforeRecordUpdateListener) adapter); + case DELETE -> events.registerListener((com.arcadedb.event.BeforeRecordDeleteListener) adapter); + } + } + case AFTER -> { + switch (trigger.getEvent()) { + case CREATE -> events.registerListener((com.arcadedb.event.AfterRecordCreateListener) adapter); + case READ -> events.registerListener((com.arcadedb.event.AfterRecordReadListener) adapter); + case UPDATE -> events.registerListener((com.arcadedb.event.AfterRecordUpdateListener) adapter); + case DELETE -> events.registerListener((com.arcadedb.event.AfterRecordDeleteListener) adapter); + } + } + } + + // Store adapter for cleanup + triggerAdapters.put(trigger.getName(), adapter); + } + + /** + * Unregister a trigger's event listener. + */ + private void unregisterTriggerListener(final String triggerName) { + final com.arcadedb.schema.trigger.TriggerListenerAdapter adapter = triggerAdapters.get(triggerName); + if (adapter == null) { + return; // Already unregistered + } + + final Trigger trigger = adapter.getTrigger(); + final LocalDocumentType type = types.get(trigger.getTypeName()); + if (type != null) { + final com.arcadedb.database.RecordEventsRegistry events = (com.arcadedb.database.RecordEventsRegistry) type.getEvents(); + + // Unregister listener based on timing and event + switch (trigger.getTiming()) { + case BEFORE -> { + switch (trigger.getEvent()) { + case CREATE -> events.unregisterListener((com.arcadedb.event.BeforeRecordCreateListener) adapter); + case READ -> events.unregisterListener((com.arcadedb.event.BeforeRecordReadListener) adapter); + case UPDATE -> events.unregisterListener((com.arcadedb.event.BeforeRecordUpdateListener) adapter); + case DELETE -> events.unregisterListener((com.arcadedb.event.BeforeRecordDeleteListener) adapter); + } + } + case AFTER -> { + switch (trigger.getEvent()) { + case CREATE -> events.unregisterListener((com.arcadedb.event.AfterRecordCreateListener) adapter); + case READ -> events.unregisterListener((com.arcadedb.event.AfterRecordReadListener) adapter); + case UPDATE -> events.unregisterListener((com.arcadedb.event.AfterRecordUpdateListener) adapter); + case DELETE -> events.unregisterListener((com.arcadedb.event.AfterRecordDeleteListener) adapter); + } + } + } + } + + // Cleanup executor resources + adapter.cleanup(); + + // Remove adapter + triggerAdapters.remove(triggerName); + } + @Override public Index getIndexByName(final String indexName) { final Index p = indexMap.get(indexName); @@ -1200,6 +1363,30 @@ protected synchronized void readConfiguration() { if (saveConfiguration) saveConfiguration(); + // LOAD TRIGGERS + if (root.has("triggers")) { + final JSONObject triggersJSON = root.getJSONObject("triggers"); + for (final String triggerName : triggersJSON.keySet()) { + final JSONObject triggerJSON = triggersJSON.getJSONObject(triggerName); + try { + final Trigger trigger = TriggerImpl.fromJSON(triggerJSON); + triggers.put(trigger.getName(), trigger); + + // Re-register trigger listeners after loading + if (existsType(trigger.getTypeName())) { + registerTriggerListener(trigger); + } else { + LogManager.instance().log(this, Level.WARNING, + "Cannot register trigger '%s' because type '%s' does not exist", + null, triggerName, trigger.getTypeName()); + } + } catch (final Exception e) { + LogManager.instance().log(this, Level.SEVERE, + "Error loading trigger '%s': %s", e, triggerName, e.getMessage()); + } + } + } + } catch (final Exception e) { LogManager.instance().log(this, Level.SEVERE, "Error on loading schema. The schema will be reset", e); } finally { @@ -1256,6 +1443,12 @@ public synchronized JSONObject toJSON() { for (final DocumentType t : this.types.values()) types.put(t.getName(), t.toJSON()); + final JSONObject triggersJson = new JSONObject(); + root.put("triggers", triggersJson); + + for (final Trigger trigger : this.triggers.values()) + triggersJson.put(trigger.getName(), trigger.toJSON()); + return root; } diff --git a/engine/src/main/java/com/arcadedb/schema/Schema.java b/engine/src/main/java/com/arcadedb/schema/Schema.java index 7fd5dc6538..1d681dae3c 100644 --- a/engine/src/main/java/com/arcadedb/schema/Schema.java +++ b/engine/src/main/java/com/arcadedb/schema/Schema.java @@ -147,6 +147,46 @@ Index createManualIndex(Schema.INDEX_TYPE indexType, boolean unique, String inde void dropIndex(String indexName); + // TRIGGER MANAGEMENT + + /** + * Check if a trigger with the given name exists. + */ + boolean existsTrigger(String triggerName); + + /** + * Get a trigger by name. + * + * @return The trigger or null if not found + */ + Trigger getTrigger(String triggerName); + + /** + * Get all triggers defined in the schema. + */ + Trigger[] getTriggers(); + + /** + * Get all triggers defined for a specific type. + */ + Trigger[] getTriggersForType(String typeName); + + /** + * Create a new trigger and register it in the schema. + * The trigger will be automatically registered as an event listener. + * + * @throws com.arcadedb.exception.CommandExecutionException if a trigger with the same name already exists + * @throws com.arcadedb.exception.CommandExecutionException if the type does not exist + */ + void createTrigger(Trigger trigger); + + /** + * Drop an existing trigger and unregister it from the event system. + * + * @throws com.arcadedb.exception.CommandExecutionException if the trigger does not exist + */ + void dropTrigger(String triggerName); + TypeBuilder buildDocumentType(); TypeBuilder buildVertexType(); diff --git a/engine/src/main/java/com/arcadedb/schema/Trigger.java b/engine/src/main/java/com/arcadedb/schema/Trigger.java new file mode 100644 index 0000000000..915d7a65df --- /dev/null +++ b/engine/src/main/java/com/arcadedb/schema/Trigger.java @@ -0,0 +1,94 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.schema; + +import com.arcadedb.serializer.json.JSONObject; +import com.arcadedb.utility.ExcludeFromJacocoGeneratedReport; + +/** + * Represents a database trigger that executes SQL or script code in response to record events. + * Triggers can be registered to fire BEFORE or AFTER CREATE, READ, UPDATE, or DELETE operations. + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +@ExcludeFromJacocoGeneratedReport +public interface Trigger { + + /** + * Timing of trigger execution relative to the event. + */ + enum TriggerTiming { + BEFORE, + AFTER + } + + /** + * Database event that triggers execution. + */ + enum TriggerEvent { + CREATE, + READ, + UPDATE, + DELETE + } + + /** + * Type of action to execute when trigger fires. + */ + enum ActionType { + SQL, + JAVASCRIPT, + JAVA + } + + /** + * Get the unique name of this trigger. + */ + String getName(); + + /** + * Get the timing (BEFORE or AFTER) of this trigger. + */ + TriggerTiming getTiming(); + + /** + * Get the event (CREATE, READ, UPDATE, DELETE) that fires this trigger. + */ + TriggerEvent getEvent(); + + /** + * Get the name of the type this trigger applies to. + */ + String getTypeName(); + + /** + * Get the type of action (SQL or JAVASCRIPT) this trigger executes. + */ + ActionType getActionType(); + + /** + * Get the action code (SQL statement or JavaScript script). + */ + String getActionCode(); + + /** + * Serialize this trigger to JSON for schema persistence. + */ + JSONObject toJSON(); +} diff --git a/engine/src/main/java/com/arcadedb/schema/TriggerImpl.java b/engine/src/main/java/com/arcadedb/schema/TriggerImpl.java new file mode 100644 index 0000000000..b97689a7e0 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/schema/TriggerImpl.java @@ -0,0 +1,141 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.schema; + +import com.arcadedb.serializer.json.JSONObject; + +import java.util.Objects; + +/** + * Implementation of the Trigger interface with JSON serialization support. + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class TriggerImpl implements Trigger { + private final String name; + private final TriggerTiming timing; + private final TriggerEvent event; + private final String typeName; + private final ActionType actionType; + private final String actionCode; + + public TriggerImpl(final String name, final TriggerTiming timing, final TriggerEvent event, + final String typeName, final ActionType actionType, final String actionCode) { + if (name == null || name.trim().isEmpty()) + throw new IllegalArgumentException("Trigger name cannot be null or empty"); + if (timing == null) + throw new IllegalArgumentException("Trigger timing cannot be null"); + if (event == null) + throw new IllegalArgumentException("Trigger event cannot be null"); + if (typeName == null || typeName.trim().isEmpty()) + throw new IllegalArgumentException("Trigger type name cannot be null or empty"); + if (actionType == null) + throw new IllegalArgumentException("Trigger action type cannot be null"); + if (actionCode == null || actionCode.trim().isEmpty()) + throw new IllegalArgumentException("Trigger action code cannot be null or empty"); + + this.name = name; + this.timing = timing; + this.event = event; + this.typeName = typeName; + this.actionType = actionType; + this.actionCode = actionCode; + } + + @Override + public String getName() { + return name; + } + + @Override + public TriggerTiming getTiming() { + return timing; + } + + @Override + public TriggerEvent getEvent() { + return event; + } + + @Override + public String getTypeName() { + return typeName; + } + + @Override + public ActionType getActionType() { + return actionType; + } + + @Override + public String getActionCode() { + return actionCode; + } + + @Override + public JSONObject toJSON() { + final JSONObject json = new JSONObject(); + json.put("name", name); + json.put("timing", timing.name()); + json.put("event", event.name()); + json.put("typeName", typeName); + json.put("actionType", actionType.name()); + json.put("actionCode", actionCode); + return json; + } + + /** + * Create a TriggerImpl from JSON representation. + */ + public static TriggerImpl fromJSON(final JSONObject json) { + return new TriggerImpl( + json.getString("name"), + TriggerTiming.valueOf(json.getString("timing")), + TriggerEvent.valueOf(json.getString("event")), + json.getString("typeName"), + ActionType.valueOf(json.getString("actionType")), + json.getString("actionCode") + ); + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final TriggerImpl trigger = (TriggerImpl) o; + return Objects.equals(name, trigger.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + + @Override + public String toString() { + return "Trigger{" + + "name='" + name + '\'' + + ", timing=" + timing + + ", event=" + event + + ", typeName='" + typeName + '\'' + + ", actionType=" + actionType + + ", actionCode='" + actionCode + '\'' + + '}'; + } +} diff --git a/engine/src/main/java/com/arcadedb/schema/trigger/JavaClassTriggerExecutor.java b/engine/src/main/java/com/arcadedb/schema/trigger/JavaClassTriggerExecutor.java new file mode 100644 index 0000000000..62612f8cbc --- /dev/null +++ b/engine/src/main/java/com/arcadedb/schema/trigger/JavaClassTriggerExecutor.java @@ -0,0 +1,109 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.schema.trigger; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Record; +import com.arcadedb.log.LogManager; + +import java.util.logging.Level; + +/** + * Executor for Java class-based triggers. + *

+ * This executor loads a user-provided Java class that implements the {@link JavaTrigger} interface + * and delegates execution to it. This is the fastest trigger option as it executes native Java code + * without any parsing or interpretation overhead. + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class JavaClassTriggerExecutor implements TriggerExecutor { + private final String triggerName; + private final String className; + private final JavaTrigger instance; + + /** + * Create a new Java class trigger executor. + * + * @param triggerName The name of the trigger (for logging) + * @param className The fully qualified class name to instantiate + * @throws TriggerExecutionException if the class cannot be loaded or instantiated + */ + public JavaClassTriggerExecutor(final String triggerName, final String className) { + this.triggerName = triggerName; + this.className = className; + + try { + // Load the class + final Class clazz = Class.forName(className); + + // Verify it implements JavaTrigger + if (!JavaTrigger.class.isAssignableFrom(clazz)) { + throw new TriggerExecutionException( + "Class '" + className + "' must implement " + JavaTrigger.class.getName()); + } + + // Instantiate the class + this.instance = (JavaTrigger) clazz.getDeclaredConstructor().newInstance(); + + } catch (final ClassNotFoundException e) { + throw new TriggerExecutionException( + "Trigger class '" + className + "' not found. Make sure the class is on the classpath.", e); + } catch (final NoSuchMethodException e) { + throw new TriggerExecutionException( + "Trigger class '" + className + "' must have a public no-argument constructor", e); + } catch (final TriggerExecutionException e) { + throw e; + } catch (final Exception e) { + throw new TriggerExecutionException( + "Failed to instantiate trigger class '" + className + "': " + e.getMessage(), e); + } + } + + @Override + public boolean execute(final Database database, final Record record, final Record oldRecord) { + try { + return instance.execute(database, record, oldRecord); + } catch (final Exception e) { + LogManager.instance().log(this, Level.SEVERE, + "Error executing Java trigger '%s' (class: %s): %s", e, triggerName, className, e.getMessage()); + throw new TriggerExecutionException( + "Java trigger '" + triggerName + "' (class: " + className + ") failed: " + e.getMessage(), e); + } + } + + @Override + public void close() { + // No resources to cleanup for Java executor + } + + /** + * Get the Java class name for this trigger. + */ + public String getClassName() { + return className; + } + + /** + * Get the loaded trigger instance. + */ + public JavaTrigger getInstance() { + return instance; + } +} diff --git a/engine/src/main/java/com/arcadedb/schema/trigger/JavaTrigger.java b/engine/src/main/java/com/arcadedb/schema/trigger/JavaTrigger.java new file mode 100644 index 0000000000..5090a0416d --- /dev/null +++ b/engine/src/main/java/com/arcadedb/schema/trigger/JavaTrigger.java @@ -0,0 +1,71 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.schema.trigger; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Record; + +/** + * Interface for user-implemented Java triggers. + *

+ * Implementations of this interface can be registered as triggers using the SQL syntax: + *

+ * CREATE TRIGGER trigger_name BEFORE CREATE ON TYPE MyType
+ * EXECUTE JAVA 'com.example.MyTriggerClass'
+ * 
+ *

+ * The class must: + *

    + *
  • Implement this interface
  • + *
  • Have a public no-argument constructor
  • + *
  • Be available on the classpath at runtime
  • + *
+ *

+ * Example implementation: + *

+ * public class MyValidationTrigger implements JavaTrigger {
+ *   @Override
+ *   public boolean execute(Database database, Record record, Record oldRecord) {
+ *     // Validate the record
+ *     if (record.asDocument().get("email") == null) {
+ *       throw new IllegalArgumentException("Email is required");
+ *     }
+ *     return true; // Continue operation
+ *   }
+ * }
+ * 
+ * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public interface JavaTrigger { + + /** + * Execute the trigger logic. + *

+ * This method is called when the trigger fires. It receives the database instance, + * the current record being operated on, and (for UPDATE operations) the original record. + * + * @param database The database instance where the operation is occurring + * @param record The current record being created/read/updated/deleted + * @param oldRecord The original record before modification (only for UPDATE events, null otherwise) + * @return true to continue the operation, false to abort it (for BEFORE triggers only) + * @throws Exception if the operation should be aborted with an error + */ + boolean execute(Database database, Record record, Record oldRecord) throws Exception; +} diff --git a/engine/src/main/java/com/arcadedb/schema/trigger/SQLTriggerExecutor.java b/engine/src/main/java/com/arcadedb/schema/trigger/SQLTriggerExecutor.java new file mode 100644 index 0000000000..798ede60d8 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/schema/trigger/SQLTriggerExecutor.java @@ -0,0 +1,68 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.schema.trigger; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Record; +import com.arcadedb.log.LogManager; + +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Level; + +/** + * Executor for SQL-based trigger actions. + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class SQLTriggerExecutor implements TriggerExecutor { + private final String triggerName; + private final String sql; + + public SQLTriggerExecutor(final String triggerName, final String sql) { + this.triggerName = triggerName; + this.sql = sql; + } + + @Override + public boolean execute(final Database database, final Record record, final Record oldRecord) { + try { + // Prepare context variables for SQL execution + final Map params = new HashMap<>(); + params.put("record", record); + params.put("$record", record); + if (oldRecord != null) { + params.put("oldRecord", oldRecord); + params.put("$oldRecord", oldRecord); + } + + // Execute SQL with context parameters + database.command("sql", sql, params); + return true; + } catch (final Exception e) { + LogManager.instance().log(this, Level.SEVERE, "Error executing SQL trigger '%s': %s", e, triggerName, e.getMessage()); + throw new TriggerExecutionException("SQL trigger '" + triggerName + "' failed: " + e.getMessage(), e); + } + } + + @Override + public void close() { + // No resources to cleanup for SQL executor + } +} diff --git a/engine/src/main/java/com/arcadedb/schema/trigger/ScriptTriggerExecutor.java b/engine/src/main/java/com/arcadedb/schema/trigger/ScriptTriggerExecutor.java new file mode 100644 index 0000000000..2ce7e317ae --- /dev/null +++ b/engine/src/main/java/com/arcadedb/schema/trigger/ScriptTriggerExecutor.java @@ -0,0 +1,102 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.schema.trigger; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Record; +import com.arcadedb.log.LogManager; +import com.arcadedb.query.polyglot.GraalPolyglotEngine; +import com.arcadedb.query.polyglot.PolyglotEngineManager; +import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.Value; + +import java.util.Arrays; +import java.util.logging.Level; + +/** + * Executor for JavaScript-based trigger actions using GraalVM Polyglot. + * Uses a shared GraalVM Engine instance from PolyglotEngineManager for optimal performance. + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class ScriptTriggerExecutor implements TriggerExecutor { + private final String triggerName; + private final String script; + private GraalPolyglotEngine scriptEngine; + + public ScriptTriggerExecutor(final String triggerName, final String script) { + this.triggerName = triggerName; + this.script = script; + } + + @Override + public boolean execute(final Database database, final Record record, final Record oldRecord) { + try { + // Create script engine if not already initialized (lazy initialization) + // Uses the shared GraalVM Engine from PolyglotEngineManager for better performance + if (scriptEngine == null) { + scriptEngine = GraalPolyglotEngine.newBuilder(database, PolyglotEngineManager.getInstance().getSharedEngine()) + .setLanguage("js") + .setAllowedPackages(Arrays.asList("java.lang.*", "java.util.*", "java.math.*")) + .build(); + } + + // Set context variables + scriptEngine.setAttribute("record", record); + scriptEngine.setAttribute("$record", record); + if (oldRecord != null) { + scriptEngine.setAttribute("oldRecord", oldRecord); + scriptEngine.setAttribute("$oldRecord", oldRecord); + } + + // Execute the script + final Value result = scriptEngine.eval(script); + + // If script returns a boolean false, abort the operation + if (result != null && result.isBoolean() && !result.asBoolean()) { + return false; + } + + return true; + } catch (final PolyglotException e) { + LogManager.instance().log(this, Level.SEVERE, "Error executing JavaScript trigger '%s': %s", e, triggerName, + GraalPolyglotEngine.endUserMessage(e, true)); + throw new TriggerExecutionException("JavaScript trigger '" + triggerName + "' failed: " + + GraalPolyglotEngine.endUserMessage(e, true), e); + } catch (final Exception e) { + LogManager.instance().log(this, Level.SEVERE, "Error executing JavaScript trigger '%s': %s", e, triggerName, + e.getMessage()); + throw new TriggerExecutionException("JavaScript trigger '" + triggerName + "' failed: " + e.getMessage(), e); + } + } + + @Override + public void close() { + if (scriptEngine != null) { + try { + scriptEngine.close(); + } catch (final Exception e) { + LogManager.instance().log(this, Level.WARNING, "Error closing script engine for trigger '%s'", e, triggerName); + } finally { + scriptEngine = null; + } + } + // Note: The shared Engine is managed by PolyglotEngineManager and should not be closed here + } +} diff --git a/engine/src/main/java/com/arcadedb/schema/trigger/TriggerExecutionException.java b/engine/src/main/java/com/arcadedb/schema/trigger/TriggerExecutionException.java new file mode 100644 index 0000000000..91d3c6373f --- /dev/null +++ b/engine/src/main/java/com/arcadedb/schema/trigger/TriggerExecutionException.java @@ -0,0 +1,37 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.schema.trigger; + +import com.arcadedb.exception.ArcadeDBException; + +/** + * Exception thrown when a trigger execution fails. + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class TriggerExecutionException extends ArcadeDBException { + + public TriggerExecutionException(final String message) { + super(message); + } + + public TriggerExecutionException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/engine/src/main/java/com/arcadedb/schema/trigger/TriggerExecutor.java b/engine/src/main/java/com/arcadedb/schema/trigger/TriggerExecutor.java new file mode 100644 index 0000000000..f22b0eb5dd --- /dev/null +++ b/engine/src/main/java/com/arcadedb/schema/trigger/TriggerExecutor.java @@ -0,0 +1,47 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.schema.trigger; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Record; +import com.arcadedb.utility.ExcludeFromJacocoGeneratedReport; + +/** + * Interface for executing trigger actions (SQL or script-based). + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +@ExcludeFromJacocoGeneratedReport +public interface TriggerExecutor { + + /** + * Execute the trigger action. + * + * @param database The database instance + * @param record The current record being operated on + * @param oldRecord The original record (for UPDATE events only, null otherwise) + * @return true to continue the operation, false to abort it + */ + boolean execute(Database database, Record record, Record oldRecord); + + /** + * Clean up any resources held by this executor (e.g., script engines). + */ + void close(); +} diff --git a/engine/src/main/java/com/arcadedb/schema/trigger/TriggerListenerAdapter.java b/engine/src/main/java/com/arcadedb/schema/trigger/TriggerListenerAdapter.java new file mode 100644 index 0000000000..245b035754 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/schema/trigger/TriggerListenerAdapter.java @@ -0,0 +1,159 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.schema.trigger; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Document; +import com.arcadedb.database.RID; +import com.arcadedb.database.Record; +import com.arcadedb.event.AfterRecordCreateListener; +import com.arcadedb.event.AfterRecordDeleteListener; +import com.arcadedb.event.AfterRecordReadListener; +import com.arcadedb.event.AfterRecordUpdateListener; +import com.arcadedb.event.BeforeRecordCreateListener; +import com.arcadedb.event.BeforeRecordDeleteListener; +import com.arcadedb.event.BeforeRecordReadListener; +import com.arcadedb.event.BeforeRecordUpdateListener; +import com.arcadedb.schema.Trigger; + +/** + * Adapter that bridges Trigger instances to the event listener system. + * Implements all 8 listener interfaces and delegates to the appropriate TriggerExecutor. + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class TriggerListenerAdapter implements + BeforeRecordCreateListener, BeforeRecordReadListener, BeforeRecordUpdateListener, BeforeRecordDeleteListener, + AfterRecordCreateListener, AfterRecordReadListener, AfterRecordUpdateListener, AfterRecordDeleteListener { + + private final Trigger trigger; + private final TriggerExecutor executor; + private final Database database; + + public TriggerListenerAdapter(final Database database, final Trigger trigger, final TriggerExecutor executor) { + this.database = database; + this.trigger = trigger; + this.executor = executor; + } + + public Trigger getTrigger() { + return trigger; + } + + public void cleanup() { + if (executor != null) { + executor.close(); + } + } + + @Override + public boolean onBeforeCreate(final Record record) { + if (trigger.getTiming() == Trigger.TriggerTiming.BEFORE && + trigger.getEvent() == Trigger.TriggerEvent.CREATE && + matchesType(record)) { + return executor.execute(database, record, null); + } + return true; + } + + @Override + public boolean onBeforeRead(final RID rid) { + if (trigger.getTiming() == Trigger.TriggerTiming.BEFORE && + trigger.getEvent() == Trigger.TriggerEvent.READ) { + // For BEFORE READ, we receive only the RID, so we need to load the record + // This is a limitation noted in the implementation plan + final Record record = rid.asDocument(); + if (matchesType(record)) { + return executor.execute(database, record, null); + } + } + return true; + } + + @Override + public boolean onBeforeUpdate(final Record record) { + if (trigger.getTiming() == Trigger.TriggerTiming.BEFORE && + trigger.getEvent() == Trigger.TriggerEvent.UPDATE && + matchesType(record)) { + // Note: oldRecord is not available in the current event system + // This could be enhanced in the future + return executor.execute(database, record, null); + } + return true; + } + + @Override + public boolean onBeforeDelete(final Record record) { + if (trigger.getTiming() == Trigger.TriggerTiming.BEFORE && + trigger.getEvent() == Trigger.TriggerEvent.DELETE && + matchesType(record)) { + return executor.execute(database, record, null); + } + return true; + } + + @Override + public void onAfterCreate(final Record record) { + if (trigger.getTiming() == Trigger.TriggerTiming.AFTER && + trigger.getEvent() == Trigger.TriggerEvent.CREATE && + matchesType(record)) { + executor.execute(database, record, null); + } + } + + @Override + public Record onAfterRead(final Record record) { + if (trigger.getTiming() == Trigger.TriggerTiming.AFTER && + trigger.getEvent() == Trigger.TriggerEvent.READ && + matchesType(record)) { + executor.execute(database, record, null); + } + return record; + } + + @Override + public void onAfterUpdate(final Record record) { + if (trigger.getTiming() == Trigger.TriggerTiming.AFTER && + trigger.getEvent() == Trigger.TriggerEvent.UPDATE && + matchesType(record)) { + // Note: oldRecord is not available in the current event system + executor.execute(database, record, null); + } + } + + @Override + public void onAfterDelete(final Record record) { + if (trigger.getTiming() == Trigger.TriggerTiming.AFTER && + trigger.getEvent() == Trigger.TriggerEvent.DELETE && + matchesType(record)) { + executor.execute(database, record, null); + } + } + + /** + * Check if the record matches the trigger's type. + */ + private boolean matchesType(final Record record) { + if (record instanceof Document) { + final String typeName = ((Document) record).getTypeName(); + return typeName != null && typeName.equals(trigger.getTypeName()); + } + return false; + } +} diff --git a/engine/src/test/java/com/arcadedb/index/Issue2802ByItemIndexOperatorsTest.java b/engine/src/test/java/com/arcadedb/index/Issue2802ByItemIndexOperatorsTest.java index 3585984a15..3743278cf0 100644 --- a/engine/src/test/java/com/arcadedb/index/Issue2802ByItemIndexOperatorsTest.java +++ b/engine/src/test/java/com/arcadedb/index/Issue2802ByItemIndexOperatorsTest.java @@ -100,7 +100,7 @@ void containsAllOperatorReturnsCorrectResults() { // For multiple values, index is NOT used (by design) to ensure correct results String explain = database.query("sql", "EXPLAIN SELECT FROM doc WHERE nums.a CONTAINSALL [2, 3]") .next().getProperty("executionPlan").toString(); - System.out.println("CONTAINSALL multi-value explain: " + explain); + //System.out.println("CONTAINSALL multi-value explain: " + explain); // This is expected - full scan with filter for correctness assertThat(explain).contains("FETCH FROM TYPE"); }); @@ -120,7 +120,7 @@ void containsAllSingleValueUsesIndex() { // For single value, index IS used String explain = database.query("sql", "EXPLAIN SELECT FROM doc WHERE nums.a CONTAINSALL [2]") .next().getProperty("executionPlan").toString(); - System.out.println("CONTAINSALL single-value explain: " + explain); + //System.out.println("CONTAINSALL single-value explain: " + explain); assertThat(explain).contains("FETCH FROM INDEX"); }); } @@ -140,7 +140,7 @@ void inOperatorUsesIndex() { // Verify index is being used - this currently fails String explain = database.query("sql", "EXPLAIN SELECT FROM doc WHERE 2 IN nums.a") .next().getProperty("executionPlan").toString(); - System.out.println("IN explain: " + explain); + //System.out.println("IN explain: " + explain); assertThat(explain).contains("FETCH FROM INDEX"); }); } @@ -157,7 +157,7 @@ void notInOperatorExecutes() { // NOT IN behavior with nested list properties may vary ResultSet result = database.query("sql", "SELECT FROM doc WHERE 2 NOT IN nums.a"); long count = result.stream().count(); - System.out.println("NOT IN count: " + count); + //System.out.println("NOT IN count: " + count); // Just verify query executes - exact semantics of NOT IN with nested properties // may need further investigation in a separate issue }); @@ -200,13 +200,13 @@ void containsAllSimpleListReturnsCorrectResults() { // For multiple values, index is NOT used (by design) explain = database.query("sql", "EXPLAIN SELECT FROM simpleDoc WHERE tags CONTAINSALL ['apple', 'banana']") .next().getProperty("executionPlan").toString(); - System.out.println("CONTAINSALL simple list explain: " + explain); + //System.out.println("CONTAINSALL simple list explain: " + explain); assertThat(explain).contains("FETCH FROM TYPE"); // Full scan with filter // Single value CONTAINSALL uses index explain = database.query("sql", "EXPLAIN SELECT FROM simpleDoc WHERE tags CONTAINSALL ['apple']") .next().getProperty("executionPlan").toString(); - System.out.println("CONTAINSALL single value explain: " + explain); + //System.out.println("CONTAINSALL single value explain: " + explain); assertThat(explain).contains("FETCH FROM INDEX"); }); } @@ -236,7 +236,7 @@ void inSimpleListUsesIndex() { // Verify index is being used String explain = database.query("sql", "EXPLAIN SELECT FROM simpleDoc2 WHERE 'apple' IN tags") .next().getProperty("executionPlan").toString(); - System.out.println("IN simple list explain: " + explain); + //System.out.println("IN simple list explain: " + explain); assertThat(explain).contains("FETCH FROM INDEX"); }); } diff --git a/engine/src/test/java/com/arcadedb/query/opencypher/Issue3216Test.java b/engine/src/test/java/com/arcadedb/query/opencypher/Issue3216Test.java index cb8fcba7de..47a2820d1e 100644 --- a/engine/src/test/java/com/arcadedb/query/opencypher/Issue3216Test.java +++ b/engine/src/test/java/com/arcadedb/query/opencypher/Issue3216Test.java @@ -86,9 +86,9 @@ void setUp() { } }); - System.out.println("Created database with vertices"); - System.out.println("Source vertex ID: " + sourceId); - System.out.println("Target vertex ID: " + targetId); + //System.out.println("Created database with vertices"); + //System.out.println("Source vertex ID: " + sourceId); + //System.out.println("Target vertex ID: " + targetId); } @AfterEach @@ -130,7 +130,7 @@ void testSimplifiedMatchWithIdFilter() { }); long duration = System.currentTimeMillis() - startTime; - System.out.println("Query execution time: " + duration + "ms"); + //System.out.println("Query execution time: " + duration + "ms"); // This should execute quickly (under 500ms even on slow systems) // If it takes multiple seconds, there's a performance issue @@ -175,7 +175,7 @@ void testUnwindMatchMergeWithIdFilter() { }); long duration = System.currentTimeMillis() - startTime; - System.out.println("UNWIND query execution time: " + duration + "ms"); + //System.out.println("UNWIND query execution time: " + duration + "ms"); // This should also execute quickly assertThat(duration) @@ -193,12 +193,12 @@ void testCartesianProductWithoutFilter() { // Count total vertices final ResultSet countRs = database.query("opencypher", "MATCH (n) RETURN count(n) as total"); long totalVertices = ((Number) countRs.next().getProperty("total")).longValue(); - System.out.println("Total vertices in database: " + totalVertices); + //System.out.println("Total vertices in database: " + totalVertices); // MATCH (a),(b) without WHERE creates Cartesian product final ResultSet rs = database.query("opencypher", "MATCH (a),(b) RETURN count(*) as total"); long cartesianCount = ((Number) rs.next().getProperty("total")).longValue(); - System.out.println("Cartesian product size: " + cartesianCount); + //System.out.println("Cartesian product size: " + cartesianCount); // Should be totalVertices * totalVertices assertThat(cartesianCount).isEqualTo(totalVertices * totalVertices); @@ -221,7 +221,7 @@ void testPerformanceComparisonMatchStrategies() { rs.next(); }); long duration1 = System.currentTimeMillis() - startTime1; - System.out.println("Method 1 (Cartesian product): " + duration1 + "ms"); + //System.out.println("Method 1 (Cartesian product): " + duration1 + "ms"); // Method 2: Separate MATCH clauses (should be faster) long startTime2 = System.currentTimeMillis(); @@ -233,7 +233,7 @@ void testPerformanceComparisonMatchStrategies() { rs.next(); }); long duration2 = System.currentTimeMillis() - startTime2; - System.out.println("Method 2 (Separate MATCH): " + duration2 + "ms"); + //System.out.println("Method 2 (Separate MATCH): " + duration2 + "ms"); // Method 2 should be significantly faster // (commenting out the assertion for now since we're diagnosing the issue) diff --git a/engine/src/test/java/com/arcadedb/query/polyglot/PolyglotEngineManagerTest.java b/engine/src/test/java/com/arcadedb/query/polyglot/PolyglotEngineManagerTest.java new file mode 100644 index 0000000000..ba3be7089d --- /dev/null +++ b/engine/src/test/java/com/arcadedb/query/polyglot/PolyglotEngineManagerTest.java @@ -0,0 +1,65 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.polyglot; + +import org.graalvm.polyglot.Engine; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Test for PolyglotEngineManager to ensure engine pooling works correctly. + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class PolyglotEngineManagerTest { + + @Test + public void testSharedEngineIsSingleton() { + final PolyglotEngineManager manager = PolyglotEngineManager.getInstance(); + Assertions.assertNotNull(manager, "PolyglotEngineManager instance should not be null"); + + // Get the shared engine multiple times + final Engine engine1 = manager.getSharedEngine(); + final Engine engine2 = manager.getSharedEngine(); + final Engine engine3 = manager.getSharedEngine(); + + Assertions.assertNotNull(engine1, "First engine instance should not be null"); + Assertions.assertNotNull(engine2, "Second engine instance should not be null"); + Assertions.assertNotNull(engine3, "Third engine instance should not be null"); + + // Verify all references point to the same instance (singleton) + Assertions.assertSame(engine1, engine2, "Engine instances should be the same object (singleton)"); + Assertions.assertSame(engine2, engine3, "Engine instances should be the same object (singleton)"); + Assertions.assertSame(engine1, engine3, "Engine instances should be the same object (singleton)"); + } + + @Test + public void testMultipleManagerInstancesShareSameEngine() { + // Even though we get multiple manager instances (singleton), they should return the same engine + final PolyglotEngineManager manager1 = PolyglotEngineManager.getInstance(); + final PolyglotEngineManager manager2 = PolyglotEngineManager.getInstance(); + + Assertions.assertSame(manager1, manager2, "Manager instances should be the same (singleton)"); + + final Engine engine1 = manager1.getSharedEngine(); + final Engine engine2 = manager2.getSharedEngine(); + + Assertions.assertSame(engine1, engine2, "Engines from different manager instances should be the same"); + } +} diff --git a/engine/src/test/java/com/arcadedb/query/sql/BenchmarkTrigger.java b/engine/src/test/java/com/arcadedb/query/sql/BenchmarkTrigger.java new file mode 100644 index 0000000000..700927e4d1 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/query/sql/BenchmarkTrigger.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.sql; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Record; +import com.arcadedb.schema.trigger.JavaTrigger; + +/** + * Simple Java trigger for benchmarking. + * Inserts a record into the audit table (same as SQL and JS triggers). + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class BenchmarkTrigger implements JavaTrigger { + + @Override + public boolean execute(final Database database, final Record record, final Record oldRecord) { + database.command("sql", "INSERT INTO JavaAudit SET triggered = true"); + return true; + } +} diff --git a/engine/src/test/java/com/arcadedb/query/sql/TestJavaAbortTrigger.java b/engine/src/test/java/com/arcadedb/query/sql/TestJavaAbortTrigger.java new file mode 100644 index 0000000000..3cf08ec5cb --- /dev/null +++ b/engine/src/test/java/com/arcadedb/query/sql/TestJavaAbortTrigger.java @@ -0,0 +1,36 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.sql; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Record; +import com.arcadedb.schema.trigger.JavaTrigger; + +/** + * Test implementation that always aborts the operation. + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class TestJavaAbortTrigger implements JavaTrigger { + + @Override + public boolean execute(final Database database, final Record record, final Record oldRecord) { + return false; // Abort the operation + } +} diff --git a/engine/src/test/java/com/arcadedb/query/sql/TestJavaTrigger.java b/engine/src/test/java/com/arcadedb/query/sql/TestJavaTrigger.java new file mode 100644 index 0000000000..f0fbbb5ad9 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/query/sql/TestJavaTrigger.java @@ -0,0 +1,41 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.sql; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Document; +import com.arcadedb.database.Record; +import com.arcadedb.schema.trigger.JavaTrigger; + +/** + * Test implementation of JavaTrigger for unit tests. + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class TestJavaTrigger implements JavaTrigger { + + @Override + public boolean execute(final Database database, final Record record, final Record oldRecord) { + // Set a flag to indicate the trigger executed + if (record instanceof Document) { + ((Document) record).modify().set("triggeredByJava", true); + } + return true; + } +} diff --git a/engine/src/test/java/com/arcadedb/query/sql/TestJavaValidationTrigger.java b/engine/src/test/java/com/arcadedb/query/sql/TestJavaValidationTrigger.java new file mode 100644 index 0000000000..08e75ad740 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/query/sql/TestJavaValidationTrigger.java @@ -0,0 +1,45 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.sql; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Document; +import com.arcadedb.database.Record; +import com.arcadedb.schema.trigger.JavaTrigger; + +/** + * Test implementation that validates email addresses. + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class TestJavaValidationTrigger implements JavaTrigger { + + @Override + public boolean execute(final Database database, final Record record, final Record oldRecord) throws Exception { + if (record instanceof Document) { + final Document doc = (Document) record; + final String email = doc.getString("email"); + + if (email == null || !email.contains("@")) { + throw new IllegalArgumentException("Invalid email address"); + } + } + return true; + } +} diff --git a/engine/src/test/java/com/arcadedb/query/sql/TriggerBenchmark.java b/engine/src/test/java/com/arcadedb/query/sql/TriggerBenchmark.java new file mode 100644 index 0000000000..88599c9ced --- /dev/null +++ b/engine/src/test/java/com/arcadedb/query/sql/TriggerBenchmark.java @@ -0,0 +1,402 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.sql; + +import com.arcadedb.database.Database; +import com.arcadedb.database.DatabaseFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Benchmark comparing SQL, JavaScript, and Java trigger performance. + * Measures the overhead of executing triggers on document operations. + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class TriggerBenchmark { + + private static Database database; + private static final int WARMUP_ITERATIONS = 10000; + private static final int BENCHMARK_ITERATIONS = 100000; + + @BeforeAll + static void setup() { + final String dbPath = "target/databases/trigger_benchmark"; + deleteDirectory(new File(dbPath)); + database = new DatabaseFactory(dbPath).create(); + } + + @AfterAll + static void teardown() { + if (database != null) { + database.drop(); + } + } + + @Test + void runBenchmark() { + System.out.println("\n" + "=".repeat(90)); + System.out.println("Trigger Performance Benchmark: SQL vs JavaScript vs Java"); + System.out.println("=".repeat(90)); + System.out.println("Warmup iterations: " + WARMUP_ITERATIONS); + System.out.println("Benchmark iterations: " + BENCHMARK_ITERATIONS); + System.out.println("=".repeat(90) + "\n"); + + Map results = new LinkedHashMap<>(); + + // Test 1: Baseline (no trigger) + System.out.println("Benchmarking: No Trigger (Baseline)"); + System.out.println("-".repeat(50)); + long baselineTime = benchmarkNoTrigger(); + results.put("No Trigger (Baseline)", baselineTime); + System.out.println(); + + // Test 2: SQL Trigger + System.out.println("Benchmarking: SQL Trigger"); + System.out.println("-".repeat(50)); + long sqlTime = benchmarkSQLTrigger(); + results.put("SQL Trigger", sqlTime); + System.out.println(); + + // Test 3: JavaScript Trigger + System.out.println("Benchmarking: JavaScript Trigger"); + System.out.println("-".repeat(50)); + long jsTime = benchmarkJavaScriptTrigger(); + results.put("JavaScript Trigger", jsTime); + System.out.println(); + + // Test 4: Java Trigger + System.out.println("Benchmarking: Java Trigger"); + System.out.println("-".repeat(50)); + long javaTime = benchmarkJavaTrigger(); + results.put("Java Trigger", javaTime); + System.out.println(); + + // Print results table + printResultsTable(results); + } + + private long benchmarkNoTrigger() { + database.transaction(() -> { + if (!database.getSchema().existsType("BaselineTest")) { + database.getSchema().createDocumentType("BaselineTest"); + } + }); + + // Warmup + System.out.print(" Warmup..."); + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + database.transaction(() -> { + database.newDocument("BaselineTest") + .set("name", "Test") + .save(); + }); + } + System.out.println(" done"); + + // Cleanup + database.transaction(() -> { + database.command("sql", "DELETE FROM BaselineTest"); + }); + + // Benchmark + System.out.print(" Benchmark..."); + long startTime = System.nanoTime(); + for (int i = 0; i < BENCHMARK_ITERATIONS; i++) { + database.transaction(() -> { + database.newDocument("BaselineTest") + .set("name", "Test") + .save(); + }); + } + long endTime = System.nanoTime(); + long totalTimeNs = endTime - startTime; + long avgTimeUs = totalTimeNs / BENCHMARK_ITERATIONS / 1000; + System.out.println(" done (" + avgTimeUs + " µs/operation)"); + + // Cleanup + database.transaction(() -> { + database.command("sql", "DELETE FROM BaselineTest"); + database.getSchema().dropType("BaselineTest"); + }); + + return avgTimeUs; + } + + private long benchmarkSQLTrigger() { + database.transaction(() -> { + if (!database.getSchema().existsType("SQLTest")) { + database.getSchema().createDocumentType("SQLTest"); + } + if (!database.getSchema().existsType("SQLAudit")) { + database.getSchema().createDocumentType("SQLAudit"); + } + database.command("sql", + "CREATE TRIGGER sql_benchmark_trigger BEFORE CREATE ON TYPE SQLTest " + + "EXECUTE SQL 'INSERT INTO SQLAudit SET triggered = true'"); + }); + + // Warmup + System.out.print(" Warmup..."); + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + database.transaction(() -> { + database.newDocument("SQLTest") + .set("name", "Test") + .save(); + }); + } + System.out.println(" done"); + + // Cleanup + database.transaction(() -> { + database.command("sql", "DELETE FROM SQLTest"); + }); + + // Benchmark + System.out.print(" Benchmark..."); + long startTime = System.nanoTime(); + for (int i = 0; i < BENCHMARK_ITERATIONS; i++) { + database.transaction(() -> { + database.newDocument("SQLTest") + .set("name", "Test") + .save(); + }); + } + long endTime = System.nanoTime(); + long totalTimeNs = endTime - startTime; + long avgTimeUs = totalTimeNs / BENCHMARK_ITERATIONS / 1000; + System.out.println(" done (" + avgTimeUs + " µs/operation)"); + + // Cleanup + database.transaction(() -> { + database.command("sql", "DROP TRIGGER sql_benchmark_trigger"); + database.command("sql", "DELETE FROM SQLTest"); + database.command("sql", "DELETE FROM SQLAudit"); + database.getSchema().dropType("SQLTest"); + database.getSchema().dropType("SQLAudit"); + }); + + return avgTimeUs; + } + + private long benchmarkJavaScriptTrigger() { + database.transaction(() -> { + if (!database.getSchema().existsType("JSTest")) { + database.getSchema().createDocumentType("JSTest"); + } + if (!database.getSchema().existsType("JSAudit")) { + database.getSchema().createDocumentType("JSAudit"); + } + database.command("sql", + "CREATE TRIGGER js_benchmark_trigger BEFORE CREATE ON TYPE JSTest " + + "EXECUTE JAVASCRIPT 'database.command(\"sql\", \"INSERT INTO JSAudit SET triggered = true\");'"); + }); + + // Warmup + System.out.print(" Warmup..."); + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + database.transaction(() -> { + database.newDocument("JSTest") + .set("name", "Test") + .save(); + }); + } + System.out.println(" done"); + + // Cleanup + database.transaction(() -> { + database.command("sql", "DELETE FROM JSTest"); + }); + + // Benchmark + System.out.print(" Benchmark..."); + long startTime = System.nanoTime(); + for (int i = 0; i < BENCHMARK_ITERATIONS; i++) { + database.transaction(() -> { + database.newDocument("JSTest") + .set("name", "Test") + .save(); + }); + } + long endTime = System.nanoTime(); + long totalTimeNs = endTime - startTime; + long avgTimeUs = totalTimeNs / BENCHMARK_ITERATIONS / 1000; + System.out.println(" done (" + avgTimeUs + " µs/operation)"); + + // Cleanup + database.transaction(() -> { + database.command("sql", "DROP TRIGGER js_benchmark_trigger"); + database.command("sql", "DELETE FROM JSTest"); + database.command("sql", "DELETE FROM JSAudit"); + database.getSchema().dropType("JSTest"); + database.getSchema().dropType("JSAudit"); + }); + + return avgTimeUs; + } + + private long benchmarkJavaTrigger() { + database.transaction(() -> { + if (!database.getSchema().existsType("JavaTest")) { + database.getSchema().createDocumentType("JavaTest"); + } + if (!database.getSchema().existsType("JavaAudit")) { + database.getSchema().createDocumentType("JavaAudit"); + } + database.command("sql", + "CREATE TRIGGER java_benchmark_trigger BEFORE CREATE ON TYPE JavaTest " + + "EXECUTE JAVA 'com.arcadedb.query.sql.BenchmarkTrigger'"); + }); + + // Warmup + System.out.print(" Warmup..."); + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + database.transaction(() -> { + database.newDocument("JavaTest") + .set("name", "Test") + .save(); + }); + } + System.out.println(" done"); + + // Cleanup + database.transaction(() -> { + database.command("sql", "DELETE FROM JavaTest"); + }); + + // Benchmark + System.out.print(" Benchmark..."); + long startTime = System.nanoTime(); + for (int i = 0; i < BENCHMARK_ITERATIONS; i++) { + database.transaction(() -> { + database.newDocument("JavaTest") + .set("name", "Test") + .save(); + }); + } + long endTime = System.nanoTime(); + long totalTimeNs = endTime - startTime; + long avgTimeUs = totalTimeNs / BENCHMARK_ITERATIONS / 1000; + System.out.println(" done (" + avgTimeUs + " µs/operation)"); + + // Cleanup + database.transaction(() -> { + database.command("sql", "DROP TRIGGER java_benchmark_trigger"); + database.command("sql", "DELETE FROM JavaTest"); + database.command("sql", "DELETE FROM JavaAudit"); + database.getSchema().dropType("JavaTest"); + database.getSchema().dropType("JavaAudit"); + }); + + return avgTimeUs; + } + + private void printResultsTable(Map results) { + System.out.println("\n" + "=".repeat(90)); + System.out.println("RESULTS SUMMARY"); + System.out.println("=".repeat(90)); + + long baseline = results.get("No Trigger (Baseline)"); + + // Header + System.out.printf("%-25s │ %15s │ %15s │ %20s%n", + "Trigger Type", "Avg Time (µs)", "Overhead (µs)", "Overhead (%)"); + System.out.println("-".repeat(25) + "─┼─" + "-".repeat(15) + "─┼─" + + "-".repeat(15) + "─┼─" + "-".repeat(20)); + + for (Map.Entry entry : results.entrySet()) { + String triggerType = entry.getKey(); + long avgTime = entry.getValue(); + long overhead = avgTime - baseline; + double overheadPct = baseline > 0 ? ((double) overhead / baseline) * 100 : 0; + + if (triggerType.equals("No Trigger (Baseline)")) { + System.out.printf("%-25s │ %15d │ %15s │ %20s%n", + triggerType, avgTime, "—", "—"); + } else { + System.out.printf("%-25s │ %15d │ %15d │ %19.1f%%%n", + triggerType, avgTime, overhead, overheadPct); + } + } + + System.out.println("=".repeat(90)); + + // Performance comparison + long sqlTime = results.get("SQL Trigger"); + long jsTime = results.get("JavaScript Trigger"); + long javaTime = results.get("Java Trigger"); + + System.out.println("\nPerformance Comparison:"); + System.out.println("-".repeat(50)); + + // Find fastest trigger type + String fastest = "Java"; + long fastestTime = javaTime; + if (sqlTime < fastestTime) { + fastest = "SQL"; + fastestTime = sqlTime; + } + if (jsTime < fastestTime) { + fastest = "JavaScript"; + fastestTime = jsTime; + } + + System.out.printf("Fastest trigger type: %s (%d µs)%n", fastest, fastestTime); + System.out.println(); + + // Relative performance + if (!fastest.equals("SQL")) { + double sqlSlower = ((double) (sqlTime - fastestTime) / fastestTime) * 100; + System.out.printf("SQL is %.1f%% slower than %s%n", sqlSlower, fastest); + } + + if (!fastest.equals("JavaScript")) { + double jsSlower = ((double) (jsTime - fastestTime) / fastestTime) * 100; + System.out.printf("JavaScript is %.1f%% slower than %s%n", jsSlower, fastest); + } + + if (!fastest.equals("Java")) { + double javaSlower = ((double) (javaTime - fastestTime) / fastestTime) * 100; + System.out.printf("Java is %.1f%% slower than %s%n", javaSlower, fastest); + } + + System.out.println("=".repeat(90) + "\n"); + } + + private static void deleteDirectory(File dir) { + if (dir.exists()) { + File[] files = dir.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + deleteDirectory(file); + } else { + file.delete(); + } + } + } + dir.delete(); + } + } +} diff --git a/engine/src/test/java/com/arcadedb/query/sql/TriggerSQLTest.java b/engine/src/test/java/com/arcadedb/query/sql/TriggerSQLTest.java new file mode 100644 index 0000000000..e19d54772c --- /dev/null +++ b/engine/src/test/java/com/arcadedb/query/sql/TriggerSQLTest.java @@ -0,0 +1,436 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.sql; + +import com.arcadedb.TestHelper; +import com.arcadedb.database.Document; +import com.arcadedb.exception.CommandExecutionException; +import com.arcadedb.exception.SchemaException; +import com.arcadedb.query.sql.executor.ResultSet; +import com.arcadedb.schema.Trigger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Comprehensive tests for SQL trigger support. + * + * @author Luca Garulli (l.garulli@arcadedata.com) + */ +public class TriggerSQLTest extends TestHelper { + + @BeforeEach + public void beginTest() { + database.transaction(() -> { + // Clean up any leftover triggers from previous tests + for (Trigger trigger : database.getSchema().getTriggers()) { + database.getSchema().dropTrigger(trigger.getName()); + } + + if (!database.getSchema().existsType("User")) { + database.getSchema().createDocumentType("User"); + } + if (!database.getSchema().existsType("AuditLog")) { + database.getSchema().createDocumentType("AuditLog"); + } + }); + } + + @Test + public void testCreateTriggerWithSQL() { + database.command("sql", + "CREATE TRIGGER audit_trigger BEFORE CREATE ON TYPE User " + + "EXECUTE SQL 'INSERT INTO AuditLog SET action = \"create\", timestamp = sysdate()'"); + + Assertions.assertTrue(database.getSchema().existsTrigger("audit_trigger")); + final Trigger trigger = database.getSchema().getTrigger("audit_trigger"); + Assertions.assertNotNull(trigger); + Assertions.assertEquals("audit_trigger", trigger.getName()); + Assertions.assertEquals(Trigger.TriggerTiming.BEFORE, trigger.getTiming()); + Assertions.assertEquals(Trigger.TriggerEvent.CREATE, trigger.getEvent()); + Assertions.assertEquals("User", trigger.getTypeName()); + Assertions.assertEquals(Trigger.ActionType.SQL, trigger.getActionType()); + } + + @Test + public void testCreateTriggerWithJavaScript() { + database.command("sql", + "CREATE TRIGGER validate_email BEFORE CREATE ON TYPE User " + + "EXECUTE JAVASCRIPT 'if (!record.email) throw new Error(\"Email required\");'"); + + Assertions.assertTrue(database.getSchema().existsTrigger("validate_email")); + final Trigger trigger = database.getSchema().getTrigger("validate_email"); + Assertions.assertNotNull(trigger); + Assertions.assertEquals(Trigger.ActionType.JAVASCRIPT, trigger.getActionType()); + } + + @Test + public void testCreateTriggerIfNotExists() { + database.command("sql", + "CREATE TRIGGER IF NOT EXISTS test_trigger BEFORE CREATE ON TYPE User " + + "EXECUTE SQL 'SELECT 1'"); + + Assertions.assertTrue(database.getSchema().existsTrigger("test_trigger")); + + // Second call should not fail with IF NOT EXISTS + database.command("sql", + "CREATE TRIGGER IF NOT EXISTS test_trigger BEFORE CREATE ON TYPE User " + + "EXECUTE SQL 'SELECT 1'"); + + Assertions.assertTrue(database.getSchema().existsTrigger("test_trigger")); + } + + @Test + public void testCreateTriggerDuplicate() { + database.command("sql", + "CREATE TRIGGER test_trigger BEFORE CREATE ON TYPE User " + + "EXECUTE SQL 'SELECT 1'"); + + // Should fail without IF NOT EXISTS + Assertions.assertThrows(CommandExecutionException.class, () -> { + database.command("sql", + "CREATE TRIGGER test_trigger BEFORE CREATE ON TYPE User " + + "EXECUTE SQL 'SELECT 1'"); + }); + } + + @Test + public void testDropTrigger() { + database.command("sql", + "CREATE TRIGGER test_trigger BEFORE CREATE ON TYPE User " + + "EXECUTE SQL 'SELECT 1'"); + + Assertions.assertTrue(database.getSchema().existsTrigger("test_trigger")); + + database.command("sql", "DROP TRIGGER test_trigger"); + + Assertions.assertFalse(database.getSchema().existsTrigger("test_trigger")); + } + + @Test + public void testDropTriggerIfExists() { + database.command("sql", "DROP TRIGGER IF EXISTS nonexistent_trigger"); + // Should not throw exception + } + + @Test + public void testDropTriggerNotExists() { + Assertions.assertThrows(CommandExecutionException.class, () -> { + database.command("sql", "DROP TRIGGER nonexistent_trigger"); + }); + } + + @Test + public void testBeforeCreateTriggerSQL() { + database.command("sql", + "CREATE TRIGGER audit_create BEFORE CREATE ON TYPE User " + + "EXECUTE SQL 'INSERT INTO AuditLog SET action = \"before_create\", timestamp = sysdate()'"); + + database.transaction(() -> { + database.newDocument("User").set("name", "John").save(); + }); + + final ResultSet result = database.query("sql", "SELECT FROM AuditLog WHERE action = 'before_create'"); + Assertions.assertTrue(result.hasNext()); + Assertions.assertEquals("before_create", result.next().getProperty("action")); + } + + @Test + public void testAfterCreateTriggerSQL() { + database.command("sql", + "CREATE TRIGGER audit_after AFTER CREATE ON TYPE User " + + "EXECUTE SQL 'INSERT INTO AuditLog SET action = \"after_create\", timestamp = sysdate()'"); + + database.transaction(() -> { + database.newDocument("User").set("name", "Jane").save(); + }); + + final ResultSet result = database.query("sql", "SELECT FROM AuditLog WHERE action = 'after_create'"); + Assertions.assertTrue(result.hasNext()); + } + + @Test + public void testBeforeUpdateTriggerSQL() { + database.command("sql", + "CREATE TRIGGER audit_update BEFORE UPDATE ON TYPE User " + + "EXECUTE SQL 'INSERT INTO AuditLog SET action = \"before_update\", timestamp = sysdate()'"); + + database.transaction(() -> { + final Document user = database.newDocument("User").set("name", "Bob").save(); + user.modify().set("name", "Robert").save(); + }); + + final ResultSet result = database.query("sql", "SELECT FROM AuditLog WHERE action = 'before_update'"); + Assertions.assertTrue(result.hasNext()); + } + + @Test + public void testBeforeDeleteTriggerSQL() { + database.command("sql", + "CREATE TRIGGER audit_delete BEFORE DELETE ON TYPE User " + + "EXECUTE SQL 'INSERT INTO AuditLog SET action = \"before_delete\", timestamp = sysdate()'"); + + database.transaction(() -> { + final Document user = database.newDocument("User").set("name", "Alice").save(); + user.delete(); + }); + + final ResultSet result = database.query("sql", "SELECT FROM AuditLog WHERE action = 'before_delete'"); + Assertions.assertTrue(result.hasNext()); + } + + @Test + public void testBeforeCreateTriggerJavaScript() { + // Simple JavaScript trigger that always succeeds + database.command("sql", + "CREATE TRIGGER js_trigger BEFORE CREATE ON TYPE User " + + "EXECUTE JAVASCRIPT 'true;'"); + + // Should pass validation + database.transaction(() -> { + database.newDocument("User").set("name", "John").save(); + }); + + final ResultSet result = database.query("sql", "SELECT FROM User WHERE name = 'John'"); + Assertions.assertTrue(result.hasNext()); + } + + @Test + public void testBeforeCreateTriggerAbort() { + database.command("sql", + "CREATE TRIGGER abort_trigger BEFORE CREATE ON TYPE User " + + "EXECUTE JAVASCRIPT 'false;'"); // Return false to abort + + // Record creation should be silently aborted (no exception, just not saved) + database.transaction(() -> { + database.newDocument("User").set("name", "Test").save(); + }); + + // Verify record was not created + final ResultSet result = database.query("sql", "SELECT FROM User WHERE name = 'Test'"); + Assertions.assertFalse(result.hasNext()); + } + + @Test + public void testMultipleTriggersSameType() { + database.command("sql", + "CREATE TRIGGER trigger1 BEFORE CREATE ON TYPE User " + + "EXECUTE SQL 'INSERT INTO AuditLog SET action = \"trigger1\"'"); + + database.command("sql", + "CREATE TRIGGER trigger2 BEFORE CREATE ON TYPE User " + + "EXECUTE SQL 'INSERT INTO AuditLog SET action = \"trigger2\"'"); + + database.transaction(() -> { + database.newDocument("User").set("name", "Test").save(); + }); + + final ResultSet result = database.query("sql", "SELECT FROM AuditLog ORDER BY action"); + Assertions.assertEquals(2, result.stream().count()); + } + + @Test + public void testTriggerPersistence() { + database.command("sql", + "CREATE TRIGGER persistent_trigger BEFORE CREATE ON TYPE User " + + "EXECUTE SQL 'INSERT INTO AuditLog SET action = \"persistent\"'"); + + Assertions.assertTrue(database.getSchema().existsTrigger("persistent_trigger")); + + // Close and reopen database + database.close(); + reopenDatabase(); + + // Trigger should still exist + Assertions.assertTrue(database.getSchema().existsTrigger("persistent_trigger")); + + // Trigger should still work + database.transaction(() -> { + database.newDocument("User").set("name", "Test").save(); + }); + + final ResultSet result = database.query("sql", "SELECT FROM AuditLog WHERE action = 'persistent'"); + Assertions.assertTrue(result.hasNext()); + } + + @Test + public void testCreateTriggerOnNonExistentType() { + Assertions.assertThrows(CommandExecutionException.class, () -> { + database.command("sql", + "CREATE TRIGGER bad_trigger BEFORE CREATE ON TYPE NonExistentType " + + "EXECUTE SQL 'SELECT 1'"); + }); + } + + @Test + public void testGetTriggers() { + database.command("sql", + "CREATE TRIGGER trigger1 BEFORE CREATE ON TYPE User " + + "EXECUTE SQL 'SELECT 1'"); + + database.command("sql", + "CREATE TRIGGER trigger2 AFTER UPDATE ON TYPE User " + + "EXECUTE SQL 'SELECT 2'"); + + final Trigger[] triggers = database.getSchema().getTriggers(); + Assertions.assertEquals(2, triggers.length); + } + + @Test + public void testGetTriggersForType() { + database.transaction(() -> { + database.getSchema().createDocumentType("Product"); + }); + + database.command("sql", + "CREATE TRIGGER user_trigger BEFORE CREATE ON TYPE User " + + "EXECUTE SQL 'SELECT 1'"); + + database.command("sql", + "CREATE TRIGGER product_trigger BEFORE CREATE ON TYPE Product " + + "EXECUTE SQL 'SELECT 2'"); + + final Trigger[] userTriggers = database.getSchema().getTriggersForType("User"); + Assertions.assertEquals(1, userTriggers.length); + Assertions.assertEquals("user_trigger", userTriggers[0].getName()); + + final Trigger[] productTriggers = database.getSchema().getTriggersForType("Product"); + Assertions.assertEquals(1, productTriggers.length); + Assertions.assertEquals("product_trigger", productTriggers[0].getName()); + } + + @Test + public void testAllEventTypes() { + // Test all 8 event types (BEFORE/AFTER × CREATE/READ/UPDATE/DELETE) + database.command("sql", + "CREATE TRIGGER t1 BEFORE CREATE ON TYPE User EXECUTE SQL 'SELECT 1'"); + database.command("sql", + "CREATE TRIGGER t2 AFTER CREATE ON TYPE User EXECUTE SQL 'SELECT 2'"); + database.command("sql", + "CREATE TRIGGER t3 BEFORE READ ON TYPE User EXECUTE SQL 'SELECT 3'"); + database.command("sql", + "CREATE TRIGGER t4 AFTER READ ON TYPE User EXECUTE SQL 'SELECT 4'"); + database.command("sql", + "CREATE TRIGGER t5 BEFORE UPDATE ON TYPE User EXECUTE SQL 'SELECT 5'"); + database.command("sql", + "CREATE TRIGGER t6 AFTER UPDATE ON TYPE User EXECUTE SQL 'SELECT 6'"); + database.command("sql", + "CREATE TRIGGER t7 BEFORE DELETE ON TYPE User EXECUTE SQL 'SELECT 7'"); + database.command("sql", + "CREATE TRIGGER t8 AFTER DELETE ON TYPE User EXECUTE SQL 'SELECT 8'"); + + Assertions.assertEquals(8, database.getSchema().getTriggers().length); + } + + @Test + public void testJavaTrigger() { + database.command("sql", + "CREATE TRIGGER java_trigger BEFORE CREATE ON TYPE User " + + "EXECUTE JAVA 'com.arcadedb.query.sql.TestJavaTrigger'"); + + database.transaction(() -> { + database.newDocument("User").set("name", "John").save(); + }); + + // Verify the Java trigger executed and set the flag + final ResultSet result = database.query("sql", "SELECT FROM User WHERE triggeredByJava = true"); + Assertions.assertTrue(result.hasNext()); + Assertions.assertEquals("John", result.next().getProperty("name")); + } + + @Test + public void testJavaValidationTrigger() { + database.command("sql", + "CREATE TRIGGER validate_email BEFORE CREATE ON TYPE User " + + "EXECUTE JAVA 'com.arcadedb.query.sql.TestJavaValidationTrigger'"); + + // Should fail validation + try { + database.transaction(() -> { + database.newDocument("User").set("name", "NoEmail").save(); + }); + Assertions.fail("Expected validation exception"); + } catch (Exception e) { + Assertions.assertTrue(e.getMessage().contains("Invalid email")); + } + + // Should pass validation + database.transaction(() -> { + database.newDocument("User").set("name", "Valid", "email", "test@example.com").save(); + }); + + final ResultSet result = database.query("sql", "SELECT FROM User WHERE email = 'test@example.com'"); + Assertions.assertTrue(result.hasNext()); + } + + @Test + public void testJavaAbortTrigger() { + database.command("sql", + "CREATE TRIGGER abort_trigger BEFORE CREATE ON TYPE User " + + "EXECUTE JAVA 'com.arcadedb.query.sql.TestJavaAbortTrigger'"); + + // Record creation should be silently aborted + database.transaction(() -> { + database.newDocument("User").set("name", "Test").save(); + }); + + // Verify record was not created + final ResultSet result = database.query("sql", "SELECT FROM User WHERE name = 'Test'"); + Assertions.assertFalse(result.hasNext()); + } + + @Test + public void testJavaTriggerPersistence() { + database.command("sql", + "CREATE TRIGGER persistent_java BEFORE CREATE ON TYPE User " + + "EXECUTE JAVA 'com.arcadedb.query.sql.TestJavaTrigger'"); + + Assertions.assertTrue(database.getSchema().existsTrigger("persistent_java")); + + // Close and reopen database + database.close(); + reopenDatabase(); + + // Trigger should still exist and work + Assertions.assertTrue(database.getSchema().existsTrigger("persistent_java")); + + database.transaction(() -> { + database.newDocument("User").set("name", "Test").save(); + }); + + final ResultSet result = database.query("sql", "SELECT FROM User WHERE triggeredByJava = true"); + Assertions.assertTrue(result.hasNext()); + } + + @Test + public void testJavaTriggerInvalidClass() { + // Should fail with class not found + Assertions.assertThrows(Exception.class, () -> { + database.command("sql", + "CREATE TRIGGER bad_trigger BEFORE CREATE ON TYPE User " + + "EXECUTE JAVA 'com.example.NonExistentClass'"); + }); + } + + @Override + protected void reopenDatabase() { + database = factory.open(); + } +} diff --git a/network/src/main/java/com/arcadedb/remote/RemoteSchema.java b/network/src/main/java/com/arcadedb/remote/RemoteSchema.java index d2eb18dcd0..a528c154b3 100644 --- a/network/src/main/java/com/arcadedb/remote/RemoteSchema.java +++ b/network/src/main/java/com/arcadedb/remote/RemoteSchema.java @@ -101,6 +101,40 @@ public void dropIndex(final String indexName) { remoteDatabase.command("sql", "drop index `" + indexName + "`"); } + // TRIGGER MANAGEMENT + + @Override + public boolean existsTrigger(final String triggerName) { + final ResultSet result = remoteDatabase.command("sql", + "select from schema:triggers where name = '" + triggerName + "'"); + return result.hasNext(); + } + + @Override + public com.arcadedb.schema.Trigger getTrigger(final String triggerName) { + throw new UnsupportedOperationException("getTrigger() is not supported in remote database"); + } + + @Override + public com.arcadedb.schema.Trigger[] getTriggers() { + throw new UnsupportedOperationException("getTriggers() is not supported in remote database"); + } + + @Override + public com.arcadedb.schema.Trigger[] getTriggersForType(final String typeName) { + throw new UnsupportedOperationException("getTriggersForType() is not supported in remote database"); + } + + @Override + public void createTrigger(final com.arcadedb.schema.Trigger trigger) { + throw new UnsupportedOperationException("createTrigger() is not supported in remote database. Use SQL CREATE TRIGGER instead."); + } + + @Override + public void dropTrigger(final String triggerName) { + remoteDatabase.command("sql", "drop trigger `" + triggerName + "`"); + } + @Override public Bucket createBucket(final String bucketName) { final ResultSet result = remoteDatabase.command("sql", "create bucket `" + bucketName + "`");