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 CypherElementId for string-based element IDs
  • Legacy (Neo4j 4.x): Use CypherId for 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

  1. Dual Serialization: Objects can be serialized to both JSON (for APIs) and Cypher (for Neo4j)
  2. Field Control: Use @CypherProperty(ignore: true) for JSON-only fields that shouldn't go to Neo4j
  3. Custom Mapping: Use @CypherProperty(name: 'customName') for different property names in Neo4j vs JSON
  4. Type Safety: Full compile-time type checking for both JSON and Cypher operations
  5. 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 - String containing Cypher node properties syntax with parameter placeholders (e.g., {name: $name, email: $email})
  • nodeLabel - String containing the Neo4j node label (from annotation or class name)
  • cypherPropertyNames - List<String> containing the property names used in Cypher

Methods

  • toCypherMap() - Returns the same as cypherParameters (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 CypherElementId fields: extracts from node.elementIdOrThrow
    • For CypherId fields (deprecated): extracts from node.id
    • Other properties extracted from node.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 to true to exclude the field from Cypher generation
  • name (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_runner for 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.