dart_neo4j_ogm 1.2.0
dart_neo4j_ogm: ^1.2.0 copied to clipboard
Annotations for Neo4j Object-Graph Mapping (OGM) code generation in Dart.
dart_neo4j_ogm #
A Neo4j Object-Graph Mapping (OGM) system for Dart that provides compile-time code generation for converting Dart classes to Cypher queries. This package works with dart_neo4j_ogm_generator to automatically generate extension methods that simplify Neo4j database interactions.
Features #
- 🚀 Compile-time code generation - No runtime reflection
- 🎯 Type-safe - Full compile-time type checking
- 🔧 Customizable - Control field mapping and ignore fields
- ❄️ Freezed compatible - Works seamlessly with Freezed classes
- 📦 Minimal dependencies - Only annotation classes, no runtime overhead
Installation #
Add both packages to your pubspec.yaml:
dependencies:
dart_neo4j_ogm: <lastest-version>
dev_dependencies:
dart_neo4j_ogm_generator: <latest-version>
build_runner: <latest-version>
Basic Usage #
1. Annotate your classes #
import 'package:dart_neo4j_ogm/dart_neo4j_ogm.dart';
part 'user.cypher.dart';
@cypherNode
class User {
// Required: All @cypherNode classes must have an ID field
// Use CypherElementId for Neo4j 5.0+ (recommended)
final CypherElementId elementId;
final String name;
final String email;
const User({
required this.elementId,
required this.name,
required this.email,
});
factory User.fromNode(Node node) => _$UserFromNode(node);
}
Note: For Neo4j 4.x or legacy code, you can use CypherId (deprecated):
@cypherNode
class User {
final CypherId id; // Deprecated: Use CypherElementId for Neo4j 5.0+
final String name;
final String email;
const User({
required this.id,
required this.name,
required this.email,
});
factory User.fromNode(Node node) => _$UserFromNode(node);
}
2. Run code generation #
dart run build_runner build
3. Use the generated methods #
import 'package:dart_neo4j/dart_neo4j.dart';
// Create a user instance (elementId will be set by Neo4j when creating nodes)
final user = User(
elementId: CypherElementId.none(), // No elementId for new nodes - Neo4j will generate one
name: 'John Doe',
email: '[email protected]',
);
// Use generated methods with Neo4j
final driver = Neo4jDriver.create('bolt://localhost:7687');
final session = driver.session();
// Create a node - elementId field is automatically excluded from properties
await session.run(
'CREATE ${user.toCypherWithPlaceholders('u')} RETURN u',
user.cypherParameters,
);
// The above generates: 'CREATE (u:User {name: $name, email: $email}) RETURN u'
// With parameters: {'name': 'John Doe', 'email': '[email protected]'}
// Note: elementId is automatically excluded from Cypher properties
// Read nodes back using the fromNode factory
final result = await session.run('MATCH (u:User) RETURN u LIMIT 1');
final record = result.records.first;
final node = record['u'] as Node;
final userFromDb = User.fromNode(node); // elementId from node.elementId, properties from node.properties
print('User Element ID from Neo4j: ${userFromDb.elementId}'); // Neo4j generated elementId
print('User name: ${userFromDb.name}');
await session.close();
await driver.close();
Advanced Usage #
ID Field Requirements #
All @cypherNode classes must have at least one ID field:
- Recommended (Neo4j 5.0+): Use
CypherElementIdfor string-based element IDs - Legacy (Neo4j 4.x): Use
CypherIdfor numeric IDs (deprecated) - Migration: You can have both types during migration
ID fields can have any name (e.g., id, elementId, nodeId, uid). The generator identifies ID fields by their type, not their name.
// Neo4j 5.0+ style (recommended)
@cypherNode
class User {
final CypherElementId elementId; // String-based element ID
final String name;
const User({required this.elementId, required this.name});
factory User.fromNode(Node node) => _$UserFromNode(node);
}
// Legacy style (still supported but deprecated)
@cypherNode
class LegacyUser {
final CypherId id; // Numeric ID (deprecated)
final String name;
const LegacyUser({required this.id, required this.name});
factory LegacyUser.fromNode(Node node) => _$LegacyUserFromNode(node);
}
// Migration style (both IDs during transition)
@cypherNode
class HybridUser {
final CypherId legacyId; // For backward compatibility
final CypherElementId elementId; // For Neo4j 5.0+ compatibility
final String name;
const HybridUser({
required this.legacyId,
required this.elementId,
required this.name,
});
factory HybridUser.fromNode(Node node) => _$HybridUserFromNode(node);
}
Custom Labels and Property Names #
@CypherNode(label: 'Person') // Custom Neo4j label
class Customer {
final CypherElementId elementId; // ID field (automatically excluded from Cypher properties)
@CypherProperty(name: 'fullName') // Custom property name in Neo4j
final String name;
@CypherProperty(ignore: true) // Exclude from Cypher generation
final String internalCode;
final double? price; // Nullable fields are handled automatically
const Customer({
required this.elementId,
required this.name,
required this.internalCode,
this.price,
});
factory Customer.fromNode(Node node) => _$CustomerFromNode(node);
}
Generated usage:
final customer = Customer(
elementId: CypherElementId.none(), // No elementId for new nodes
name: 'Jane Smith',
internalCode: 'INTERNAL_123',
price: 99.99,
);
print(customer.nodeLabel); // 'Person'
print(customer.cypherParameters);
// {'fullName': 'Jane Smith', 'price': 99.99}
// Note: elementId is automatically excluded, internalCode is excluded due to @CypherProperty(ignore: true)
print(customer.toCypherWithPlaceholders('c'));
// '(c:Person {fullName: $fullName, price: $price})'
// Note: ID fields are automatically excluded from Cypher properties
Freezed Integration #
This package works seamlessly with Freezed classes. You need to configure your build system to run Freezed before the OGM generator.
Build Configuration #
Create or update your build.yaml file:
targets:
$default:
builders:
freezed:freezed:
runs_before: ['dart_neo4j_ogm_generator:cypher_generator']
dart_neo4j_ogm_generator:cypher_generator:
enabled: true
Freezed Class Example #
import 'package:dart_neo4j_ogm/dart_neo4j_ogm.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.cypher.dart';
@freezed
@cypherNode
class User with _$User {
const factory User({
required CypherElementId elementId, // ID field (automatically excluded from Cypher properties)
required String name,
@CypherProperty(name: 'emailAddress')
required String email,
@CypherProperty(ignore: true)
required String password,
String? bio,
}) = _User;
factory User.fromNode(Node node) => _$UserFromNode(node);
}
Usage with Freezed:
final user = User(
elementId: CypherElementId.none(), // No elementId for new nodes
name: 'Alice Johnson',
email: '[email protected]',
password: 'secret123',
bio: 'Software Developer',
);
print(user.cypherParameters);
// {'name': 'Alice Johnson', 'emailAddress': '[email protected]', 'bio': 'Software Developer'}
// Note: elementId is automatically excluded, password is excluded due to @CypherProperty(ignore: true)
print(user.toCypherWithPlaceholders('u'));
// '(u:User {name: $name, emailAddress: $emailAddress, bio: $bio})'
// Note: ID fields are automatically excluded from Cypher properties
JSON Serialization with Freezed + json_serializable #
The OGM system works seamlessly with Freezed classes that also use json_serializable for JSON serialization. This is particularly useful when you need to serialize your Neo4j entities to/from JSON for APIs or storage.
Build Configuration for JSON Support #
When using both Freezed and json_serializable, update your build.yaml to ensure proper build order:
targets:
$default:
builders:
freezed:freezed:
runs_before: ['dart_neo4j_ogm_generator:cypher_generator']
json_serializable:json_serializable:
runs_before: ['dart_neo4j_ogm_generator:cypher_generator']
dart_neo4j_ogm_generator:cypher_generator:
enabled: true
ID JSON Serialization #
The package provides built-in helper functions for ID JSON serialization:
For CypherElementId (recommended):
cypherElementIdToJson(CypherElementId elementId)- Converts to JSON (String? value)cypherElementIdFromJson(String? json)- Creates from JSON value
For CypherId (deprecated):
cypherIdToJson(CypherId id)- Converts to JSON (int? value)cypherIdFromJson(int? json)- Creates from JSON value
Complete JSON + Neo4j Example #
import 'package:dart_neo4j_ogm/dart_neo4j_ogm.dart';
import 'package:dart_neo4j/dart_neo4j.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'json_user.freezed.dart';
part 'json_user.g.dart';
part 'json_user.cypher.dart';
@freezed
@CypherNode()
class JsonUser with _$JsonUser {
const factory JsonUser({
@JsonKey(toJson: cypherElementIdToJson, fromJson: cypherElementIdFromJson)
required CypherElementId elementId,
required String name,
required String email,
@CypherProperty(name: 'userAge') int? age,
@CypherProperty(ignore: true) String? internalNotes,
}) = _JsonUser;
factory JsonUser.fromJson(Map<String, dynamic> json) =>
_$JsonUserFromJson(json);
factory JsonUser.fromNode(Node node) => _$JsonUserFromNode(node);
}
For legacy CypherId (deprecated):
@freezed
@CypherNode()
class LegacyJsonUser with _$LegacyJsonUser {
const factory LegacyJsonUser({
@JsonKey(toJson: cypherIdToJson, fromJson: cypherIdFromJson)
required CypherId id, // Deprecated: Use CypherElementId
required String name,
required String email,
}) = _LegacyJsonUser;
factory LegacyJsonUser.fromJson(Map<String, dynamic> json) =>
_$LegacyJsonUserFromJson(json);
factory LegacyJsonUser.fromNode(Node node) => _$LegacyJsonUserFromNode(node);
}
Usage with JSON and Neo4j #
Future<void> jsonExample() async {
// 1. Create from JSON (e.g., from API request)
final jsonData = {
'elementId': '4:abc123:42',
'name': 'John Doe',
'email': '[email protected]',
'userAge': 30,
'internalNotes': 'This will be ignored in Cypher'
};
final user = JsonUser.fromJson(jsonData);
print('User from JSON: \${user.name}, Age: \${user.age}');
// 2. Use with Neo4j (internalNotes is ignored due to @CypherProperty(ignore: true))
final driver = Neo4jDriver.create('bolt://localhost:7687');
final session = driver.session();
try {
// Create in Neo4j - only name, email, and userAge are included
await session.run(
'CREATE \${user.toCypherWithPlaceholders('u')} RETURN u',
user.cypherParameters,
);
// Generated: CREATE (u:JsonUser {name: \$name, email: \$email, userAge: \$userAge}) RETURN u
// 3. Read from Neo4j
final result = await session.run('MATCH (u:JsonUser) RETURN u LIMIT 1');
final node = result.records.first['u'] as Node;
final userFromDb = JsonUser.fromNode(node);
// 4. Convert back to JSON (e.g., for API response)
final jsonResponse = userFromDb.toJson();
print('User as JSON: \$jsonResponse');
// Output: {elementId: '4:abc123:99', name: John Doe, email: [email protected], userAge: 30, internalNotes: null}
} finally {
await session.close();
await driver.close();
}
}
Key Benefits #
- Dual Serialization: Objects can be serialized to both JSON (for APIs) and Cypher (for Neo4j)
- Field Control: Use
@CypherProperty(ignore: true)for JSON-only fields that shouldn't go to Neo4j - Custom Mapping: Use
@CypherProperty(name: 'customName')for different property names in Neo4j vs JSON - Type Safety: Full compile-time type checking for both JSON and Cypher operations
- ID Handling: Proper CypherId serialization with custom JSON converters
Dependencies #
Add these to your pubspec.yaml:
dependencies:
dart_neo4j_ogm: <latest-version>
freezed_annotation: <latest-version>
json_annotation: <latest-version>
dev_dependencies:
dart_neo4j_ogm_generator: <latest-version>
build_runner: <latest-version>
freezed: <latest-version>
json_serializable: <latest-version>
Avoiding Parameter Name Collisions #
When working with complex queries involving multiple nodes, parameter names can collide. The OGM provides prefixed methods to solve this:
final user = User(elementId: CypherElementId.none(), name: 'John', email: '[email protected]');
final post = BlogPost(elementId: CypherElementId.none(), title: 'Hello World', content: 'My first post', name: 'John');
// Without prefixes - parameter collision on 'name'!
// This would cause issues: {...user.cypherParameters, ...post.cypherParameters}
// Both objects have a 'name' field, causing parameter collision
// With prefixes - no collisions
await session.run('''
CREATE ${user.toCypherWithPlaceholdersWithPrefix('u', 'user_')}
CREATE ${post.toCypherWithPlaceholdersWithPrefix('p', 'post_')}
CREATE (u)-[:AUTHORED]->(p)
''', {
...user.cypherParametersWithPrefix('user_'),
...post.cypherParametersWithPrefix('post_'),
});
// Generated query (note: ID fields are automatically excluded):
// CREATE (u:User {name: $user_name, email: $user_email})
// CREATE (p:BlogPost {title: $post_title, content: $post_content, name: $post_name})
// CREATE (u)-[:AUTHORED]->(p)
// With parameters:
// {
// 'user_name': 'John', 'user_email': '[email protected]',
// 'post_title': 'Hello World', 'post_content': 'My first post', 'post_name': 'John'
// }
Generated API Reference #
The code generator creates extension methods on your annotated classes:
Properties #
cypherParameters-Map<String, dynamic>containing field values for Cypher queries (excludes ID fields)cypherProperties-Stringcontaining Cypher node properties syntax with parameter placeholders (e.g.,{name: $name, email: $email})nodeLabel-Stringcontaining the Neo4j node label (from annotation or class name)cypherPropertyNames-List<String>containing the property names used in Cypher
Methods #
toCypherMap()- Returns the same ascypherParameters(alias for consistency)toCypherWithPlaceholders(String variableName)- Returns complete Cypher node syntax with variable name, label, and properties (e.g.,(u:User {name: $name, email: $email}))cypherPropertiesWithPrefix(String prefix)- Returns Cypher properties string with prefixed parameter placeholders (e.g.,{name: $user_name, email: $user_email})cypherParametersWithPrefix(String prefix)- Returns parameter map with prefixed keys to avoid name collisions (e.g.,{'user_name': 'John', 'user_email': '[email protected]'})toCypherWithPlaceholdersWithPrefix(String variableName, String prefix)- Returns complete Cypher node syntax with prefixed parameter placeholders (e.g.,(u:User {name: $user_name, email: $user_email}))
Factory Methods #
The generator creates static factory methods for creating instances from Neo4j Node objects:
fromNode(Node node)- Creates an instance from a Neo4j Node object- For
CypherElementIdfields: extracts fromnode.elementIdOrThrow - For
CypherIdfields (deprecated): extracts fromnode.id - Other properties extracted from
node.properties
- For
Annotations Reference #
@cypherNode / @CypherNode #
Marks a class for Cypher code generation.
@cypherNode // Uses class name as label
// or
@CypherNode(label: 'CustomLabel') // Uses custom label
Parameters:
label(optional): Custom Neo4j node label. Defaults to class name.
@CypherProperty #
Controls how individual fields are handled in Cypher generation.
@CypherProperty(
ignore: false, // Whether to exclude this field (default: false)
name: null, // Custom property name in Neo4j (default: field name)
)
Parameters:
ignore(optional): Set totrueto exclude the field from Cypher generationname(optional): Custom property name to use in Neo4j instead of the field name
Examples #
Complete Neo4j Integration Example #
import 'package:dart_neo4j/dart_neo4j.dart';
import 'package:dart_neo4j_ogm/dart_neo4j_ogm.dart';
part 'models.cypher.dart';
@cypherNode
class User {
final CypherElementId elementId; // ID field (any name allowed)
final String name;
final String email;
const User({required this.elementId, required this.name, required this.email});
factory User.fromNode(Node node) => _$UserFromNode(node);
}
@CypherNode(label: 'Post')
class BlogPost {
final CypherElementId elementId; // ID field (any name allowed)
final String title;
final String content;
final String? authorElementId; // Store as String for Neo4j 5.0+ compatibility
const BlogPost({
required this.elementId,
required this.title,
required this.content,
this.authorElementId,
});
factory BlogPost.fromNode(Node node) => _$BlogPostFromNode(node);
}
// Usage
Future<void> example() async {
final driver = Neo4jDriver.create('bolt://localhost:7687');
final session = driver.session();
final user = User(elementId: CypherElementId.none(), name: 'John', email: '[email protected]');
final post = BlogPost(
elementId: CypherElementId.none(),
title: 'Hello World',
content: 'My first post',
authorElementId: null, // Will be set to actual user elementId after creation
);
try {
// Option 1: Create user first, then create post with relationship
final userResult = await session.run(
'CREATE ${user.toCypherWithPlaceholders('u')} RETURN u',
user.cypherParameters,
);
final createdUserNode = userResult.records.first['u'] as Node;
final createdUser = User.fromNode(createdUserNode);
// Update post with actual user elementId
final postWithUserId = BlogPost(
elementId: CypherElementId.none(),
title: post.title,
content: post.content,
authorElementId: createdUser.elementId.elementIdOrThrow,
);
await session.run('''
MATCH (u:User) WHERE elementId(u) = \$authorElementId
CREATE ${postWithUserId.toCypherWithPlaceholders('p')}
CREATE (u)-[:AUTHORED]->(p)
''', postWithUserId.cypherParameters);
// Option 2: Create both nodes with prefixed parameters (no relationship)
final newUser = User(elementId: CypherElementId.none(), name: 'Jane', email: '[email protected]');
final newPost = BlogPost(
elementId: CypherElementId.none(),
title: 'Another Post',
content: 'Different content',
authorElementId: null,
);
await session.run('''
CREATE ${newUser.toCypherWithPlaceholdersWithPrefix('u', 'user_')}
CREATE ${newPost.toCypherWithPlaceholdersWithPrefix('p', 'post_')}
''', {
...newUser.cypherParametersWithPrefix('user_'),
...newPost.cypherParametersWithPrefix('post_'),
});
// The above generates (note: elementId fields are automatically excluded):
// CREATE (u:User {name: $user_name, email: $user_email})
// CREATE (p:Post {title: $post_title, content: $post_content, authorElementId: $post_authorElementId})
//
// With parameters: {
// 'user_name': 'Jane', 'user_email': '[email protected]',
// 'post_title': 'Another Post', 'post_content': 'Different content', 'post_authorElementId': null
// }
// Query with results using fromNode factories
final result = await session.run(
'MATCH (u:User)-[:AUTHORED]->(p:Post) RETURN u, p',
);
// Read results using fromNode factories
await for (final record in result) {
final userNode = record['u'] as Node;
final postNode = record['p'] as Node;
final retrievedUser = User.fromNode(userNode);
final retrievedPost = BlogPost.fromNode(postNode);
print('User: ${retrievedUser.name} (ElementID: ${retrievedUser.elementId.elementIdOrThrow})');
print('Post: ${retrievedPost.title} (ElementID: ${retrievedPost.elementId.elementIdOrThrow})');
}
} finally {
await session.close();
await driver.close();
}
}
Requirements #
- Dart SDK: >=3.0.0
- Compatible with both regular Dart classes and Freezed classes
- Requires
build_runnerfor code generation
Contributing #
Contributions are welcome! Please read our contributing guidelines and submit pull requests to our GitHub repository.
License #
This project is licensed under the GNU General Public License v3.0 - see the LICENSE file for details.