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.
Libraries
- dart_neo4j_ogm
- Neo4j Object-Graph Mapping (OGM) annotations for Dart.