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 {
final CypherId id; // Required: All @cypherNode classes must have a CypherId id field
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 (id will be set by Neo4j when creating nodes)
final user = User(
id: CypherId.none(), // No id for new nodes - Neo4j will generate one when created
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 - id 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: id 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); // id comes from node.id, properties from node.properties
print('User ID from Neo4j: ${userFromDb.id}'); // Neo4j generated id
print('User name: ${userFromDb.name}');
await session.close();
await driver.close();
Advanced Usage
Custom Labels and Property Names
@CypherNode(label: 'Person') // Custom Neo4j label
class Customer {
final CypherId id; // Required: CypherId 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.id,
required this.name,
required this.internalCode,
this.price,
});
factory Customer.fromNode(Node node) => _$CustomerFromNode(node);
}
Generated usage:
final customer = Customer(
id: CypherId.none(), // No id for new nodes - Neo4j will generate one when created
name: 'Jane Smith',
internalCode: 'INTERNAL_123',
price: 99.99,
);
print(customer.nodeLabel); // 'Person'
print(customer.cypherParameters);
// {'fullName': 'Jane Smith', 'price': 99.99}
// Note: id is automatically excluded, internalCode is excluded due to @CypherProperty(ignore: true)
print(customer.toCypherWithPlaceholders('c'));
// '(c:Person {fullName: $fullName, price: $price})'
// Note: id field is 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 CypherId id, // Required: CypherId 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(
id: CypherId.none(), // No id for new nodes - Neo4j will generate one when created
name: 'Alice Johnson',
email: '[email protected]',
password: 'secret123',
bio: 'Software Developer',
);
print(user.cypherParameters);
// {'name': 'Alice Johnson', 'emailAddress': '[email protected]', 'bio': 'Software Developer'}
// Note: id 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 field is 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
CypherId JSON Serialization
The package provides built-in helper functions for CypherId JSON serialization. These are automatically available when you import dart_neo4j_ogm:
cypherIdToJson(CypherId id)- Converts CypherId to JSON (int? value)cypherIdFromJson(int? json)- Creates CypherId 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: cypherIdToJson, fromJson: cypherIdFromJson)
required CypherId id,
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);
}
Usage with JSON and Neo4j
Future<void> jsonExample() async {
// 1. Create from JSON (e.g., from API request)
final jsonData = {
'id': 123,
'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: {id: 456, 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(id: CypherId.none(), name: 'John', email: '[email protected]');
final post = BlogPost(id: CypherId.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 field)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, extracting the id fromnode.idand other properties fromnode.properties
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 CypherId id; // Required CypherId id field
final String name;
final String email;
const User({required this.id, required this.name, required this.email});
factory User.fromNode(Node node) => _$UserFromNode(node);
}
@CypherNode(label: 'Post')
class BlogPost {
final CypherId id; // Required CypherId id field
final String title;
final String content;
final CypherId authorId;
const BlogPost({
required this.id,
required this.title,
required this.content,
required this.authorId,
});
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(id: CypherId.none(), name: 'John', email: '[email protected]');
final post = BlogPost(
id: CypherId.none(),
title: 'Hello World',
content: 'My first post',
authorId: CypherId.none(), // Will be set to actual user id 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 id
final postWithUserId = BlogPost(
id: CypherId.none(),
title: post.title,
content: post.content,
authorId: CypherId.value(createdUser.id.idOrThrow),
);
await session.run('''
MATCH (u:User) WHERE id(u) = \$authorId
CREATE ${postWithUserId.toCypherWithPlaceholders('p')}
CREATE (u)-[:AUTHORED]->(p)
''', postWithUserId.cypherParameters);
// Option 2: Create both nodes with prefixed parameters (no relationship)
final newUser = User(id: CypherId.none(), name: 'Jane', email: '[email protected]');
final newPost = BlogPost(
id: CypherId.none(),
title: 'Another Post',
content: 'Different content',
authorId: CypherId.none(),
);
await session.run('''
CREATE ${newUser.toCypherWithPlaceholdersWithPrefix('u', 'user_')}
CREATE ${newPost.toCypherWithPlaceholdersWithPrefix('p', 'post_')}
''', {
...newUser.cypherParametersWithPrefix('user_'),
...newPost.cypherParametersWithPrefix('post_'),
});
// The above generates (note: id fields are automatically excluded):
// CREATE (u:User {name: $user_name, email: $user_email})
// CREATE (p:Post {title: $post_title, content: $post_content, authorId: $post_authorId})
//
// With parameters: {
// 'user_name': 'Jane', 'user_email': '[email protected]',
// 'post_title': 'Another Post', 'post_content': 'Different content', 'post_authorId': 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} (ID: ${retrievedUser.id.idOrThrow})');
print('Post: ${retrievedPost.title} (ID: ${retrievedPost.id.idOrThrow})');
}
} 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.