ict.ken.be

 

Building Data-Centric Single Apps with Breeze - Notes

Related Posts

Categories: Javascript Notes

by Brian Noyes (brian.noyes@solliance.net)
www.solliance.net

Server Side

  • BreezeController vs OData vs client enriching

Query Calling Pattern

  • Client > executeQuery(query) > Entity Manager > GET Metadata from Controller (first time) > GET Query the controller > Return Query Results

SaveChanges Calling Pattern

  • Client > saveChanges > Entity Manager > POST Modified Entity ChangeSet > Controller > Return Server Persisted Entities

RPC, CRUD, REST, ODATA Services

  • Breeze routing is action-based (contains an entire model) > by default using a WebActivator.PreApplicationStartMethod(...)
  • OData query parameters are turned into an expression tree that is executed against the return IQueryable by the query filter
  • SaveChanges takes a JObject bundle with a batch of entities.

Extend EFContextProvider to add custom code

  • BeginSaveEntity, BeginSaveEntities (Derived or Delegated)
_ContextProvider.BeforeSaveEntitiesDelegate = BeforeSaveEntities;
private Dictionary<Type, List<EntityInfo>> BeforeSaveEntities(Dictionary<Type, List<EntityInfo> saveMap)
{
var validator = new ProductValidator();
foreach (var type in saveMap.Keys)
{
if (type == typeof(Product))
foreach (var productEntityInfo in saveMap[type])
{
validator.Validate((Product)productEntityInfo.Entity)
}
}
}

Working with OData Services

  • On client data.js is needed
  • Server needs to define Entity Data Model with same namespace as entity types namespace
  • Metadata returned from the server needs to include foreign key relation information
  • Need some changes to the way you setup Entity Data Model and return metadata from the server
public class ZzaODataService : DataService<ZzaEntities>
{
public static void InitializeService(DataServiceConfiguration config)
{
config.SetEntitySetAccessRule("Products", EntitySetRights.All);
config.DataServiceBehavior.MaxProtocolVersion = DataServiceProtocolVersion.V3;
}
}

public static void RegisterRoutes(RouteCollection routes)
{
...
routes.IgnoreRoute("{resource}.svc/{*pathInfo}");
...
}

  • edmx > properties > Namespace: ZzaODataWeb (same project space as service)

app = {};
breeze.config.initializeAdapterInstance({dataService:"OData"});
em = new breeze.EntityManager("ZzaODataService.svc");

  • GET /ZzaODataService.svc/$metadata (returns xml)
  • GET /ZzaODataService.svc/Products
  • POST /ZzaODataService.svc/$batch (multi-part mime message)

Breeze Query Basics

app.em = new breeze.EntityManager("breeze/Zza");
breeze.EntityQuery.from("Products").where("Description","contains","bacon");
app.em.executeQuery(query).then(...).fail(...);

var p = new breeze.Predicate("Description","contains","bacon");
... .where(p);

var gteMay1 = new breeze.Predicate("OrderDate", "greaterThanOrEqual", moment("2013-05-01");
var lteMay31 = new breeze.Predicate("OrderDate", breeze.FilterQueryOp.LessThanOrEqual, moment("2013-05-31");
...
var predicate = breeze.Predicate.and(gteMay1, lteMay31, gt100);

... orderBy("Name");
... orderBy("TotalPrice, Order.OrderDate");
... orderBy("OrderDate desc").skip(currentPage() * pageSize).take(pageSize);

... orderBy("OrderDate desc").skip(currentPage() * pageSize).take(pageSize).inlineCount();

... select("LastName, Phone, State");
... from("OrderItems").where("Order.Customer.LastName","equals", queryInput()).select("Order.OrderDate, Product.Name, Quantity");
-> use Product_Name, Order_OrderDate to reference the projected elements.

... query.expand("Orders,Orders.OrderDetails,Orders.OrderDetails.Product"); //eagerloading

Breeze Query Advanced

Pre-fetching Metadata is needed when getting by id or creating.
... EntityManager.fetchMetadata().fail(...);

... EntityManager.fetchEntityByKey("Products", key).then(...);


public object Lookups()
{
var products = _ContextProvider.Context.Products.ToList();
...
return { products, productOptions };
}
breeze.EntityQuery.from("Lookups");
... data.results[0].products
... data.results[0].productOptions

public IQueryable<OrderItem> OrderItemsWithCouponCode(string couponCode, string customerClass)
{
...
}
var query = breeze.EntityQuery.from("OrderItemsWithCouponCode").withParameters({ couponCode:"XYZ", customerClass:"Gold"});

Server-Driven Queries (group by, sum, ...)
public IQueryable<object> TopCustomers()
{
var query = (from o in _ContextProvider.Context.Orders
group o by o.CustomerId into g
select new { Customer = g.FirstOrDefault().Customer.LastName, Total = g.Sum(o => o.ItemsTotal) }).OrderByDescending(a => a.Total);
return query;
}
... from("TopCustomers").take(10);

... requeryProducts.push(product)
... fromEntities(requeryProducts); //or by id that replaces the cache, you can also put a sigle entity

breeze.EntityQuery.from("Products").where(...)
... em.executeQueryLocally(query); //synchronous
query.using(breeze.FetchStrategy.FromLocalCache); //async with promise

EntityAspect: contains metadata and state information about the entity

  • breeze.NamingConvention.camelCase.setAsDefault(); //type and collections are pascal case, properties or camelcase

EntityManager.createEntity: creates instance and add to cache
newCustomer = function() {
var cust - em.createEntity("Customer", { id: breeze.core.getUuid() });
return cust;
}

EntityType.createEntity: reference from meta, entity from meta, create, add to cache
newCustomer = function() {
var metadataStore = em.metadataStore;
var custType = metadataStore.getEntityType("Customer");
var cust = custType.createEntity({ id: breeze.core.getUuid() });
em.addEntity(cust);
return cust;
}

var pendingStatus = em.executeQueryLocally(breeze.EntityQuery.from("OrderStatuses").where("name", "equals", "Pending"))[0];
order.orderStatusId(pendingStatus.id());
return order;

addToOrder = function(product) {
if (!Order()) order(zzaDataService.createNewOrder(customer()));
var orderItem = order().entityAspect.entityManager.createEntity("OrderItem");
orderItem.order(order());
orderItem.product(product);
orderItem.productSizeId(1);
orderItems.push(orderItem);
}

editingCustomer().entityAspect.rejectChanges();
em.rejectChanges();

//after accepting changes you will not be able to persist them !
... entityAspect.acceptChanges(); //should only be used when mocking data

customer.entityAspect.setDeleted();
customers.remove(customer);

Named Saves
zzaDataService.saveCustomers().fail(...)
...
var customers = em.getChanges("Customer");
return em.saveChanges(customers);
...
var saveOptions = new breeze.SaveOptions({ resourceName:"SaveOrder" });
var orders = [order];
return em.saveChanges(orders, saveOptions);
...
[HttpPost]
public SaveResult SaveOrder(JObject saveBundle)
{
return _ContextProvider.SaveChanges(saveBundle);
}

Working with Entities on the client

Entity States:

  • Unchanged - after being loaded by a query or after successful saveChanges
  • Modified - previously unchanged entity has a property change
  • Added - entity has been created and added to the cache, but not saved to back end
  • Deleted - previously unchanged entity is marked for deletion
  • Detached

Newly created entity that has not been added to the cache
Added entity that gets marked for deletion
Deleted entity after saveChanges is complete
Entities that were in the cache when EntityManager.clear is called
EntityManager.detachEntity called
EntityAspect.entityState
createEntity, setDeleted, rejectChanges, acceptChanges
EntityAspect.setModified, EntityAspect.setUnchanged

Extending Entities: Modify the EntityType in the Breeze metadataStore
Saved when exported, but not persisted to the server.

addEntityExtensions = function () {
var store = em.metadataStore;
store.registerEntityTypeCtor("Customer", Customer)
}
var Customer = function() {
this.firstName = ko.observable(''); //solves race condition
this.lastName = ko.observable('');
this.fullName = ko.computed(function() {
return this.firstName() + " " + this.lastName();
}, this);
}
OR
Customer.prototype.getFullName = function() {
return this.firstName() + " " + this.lastName();
}

Handling Entity Property Changes

  • Breeze raises its own (observable agnostic) property change events
  • EntityAspect.propertyChanged.subscribe returns a subscription token
  • Avoid memory leaks by EntityAspect.propertyChanged.unsubscribe(token)
tokens = {};
var token = addingCustomer().entityAspect.propertyChanged.subscribe(function()arg {
logger.info("Customer:" + arg.entity.fullName() + "property " + arg.propertyName + "changed value" + arg.oldValue + "into" + arg.newValue);
})
tokens[addingCustomer().id] = token;
... unsubscribe(tokens[customers()[i].id]) ...

EntityManager.entityChanged.subscribe (eg. undo)
-> entityAction, entity, args

Exporting and importing cached entities
Using an entityChangedHandler to make sure changes are saved locally for when user would close browser before saving
var bundle = em.exportEntities(em.getChanges());
window.localStorage.setItem("magickey", bundle);
...
var bundle = window.localStorage.getItem("magickey");
if (bundle) em.importEntities(bundle);
...
clear local storage on save

Validation

  • Returns HTTP 403 Forbidden status code when validation errors
  • Collection of ValidationError objects per entity (propertyName, property, errorMessage, context, isServerError, key, validator)
  • Automatically adds type, required and length validation
  • Configurable - EntityManager.validationProperties
saveError = function (error) {
if (error.entityErrors) {
showValidationErrors(error.entityErrors);
}
else {
logger.error(error.message, "Error saving data");
}
}
showValidationErrors = function (errors) {
var errorMessage = "";
errors.map(function (e) {
if (errorMessage.length > 0) errorMessage += ", ";
errorMessage += e.errorMessage;
});
logger.error(errorMessage, "Validation Errors");
}

Data Annotations (System.ComponentModel.DataAnnotations)
[Required], [StringLength(50)], Range, RegularExpresssion, Compare
[DataType(DataType.Currency)], Date, Time, DateTime, PostalCode, ...
Phone, [EmailAddress], CreditCard, Url

function initValidation(em) {
var store = em.metadataStore;
var userType = store.getEntityType("User");
var phoneProp = userType.getProperty("phone");
phoneProp.validators.push(breeze.Validator.phone());
}

var userKeyValidator = breeze.Validator.makeRegExpValidator(
"userKeyVal",
/^[A-Z] ... regex ... $/,
"%displayName% '%value%' is not a valid GUID");
);

if (validateUser(editingUser()) { ... saveChanges ... }
validateUser = function(user) {
if (!user.entityAspect.validateEnitity()) {
var errors = user.entityAspect.getValidationErrors();
showValidationErrors(errors);
return false;
}
return true;
}


function isValidScoreRange(value, context) {
return value > context.minValue && value <= context.maxValue;
}
var scoreRangeValidator = new breeze.Validator("scoreRangeValidator", isValidScoreRange, { messageTemplate: '...' });
userType.getProperty("score").validators.push(scoreRangeValidator);
function rangeValidatorFactory(context) {
return new breeze.Validator("rangeValidator", isValidRange,
{
minValue: context.minValue,
maxValue: context.maxValue,
messageTemplate: '... %minValue% ... %maxValue% ...'
OR messageTemplate: breeze.core.formatString("'... %1 ... %2'", context.minValue, context.maxValue)
}
);
}
userType.getProperty("score").validators.push(rangeValidatorFactory({ minValue:1, maxValue:100 }));

//register to allow export and imports
breeze.Validator.register(userKeyValidator);
breeze.validator.registerFactory(rangeValidatorFactory, "rangeValidator");


app.em.validationErrorChanged.subscribe(function(args) {
if (args.added) ...
}


editSubscriptionToken = editingUser().entityAspect.validationErrorsChanged.subscribe(...);
... unsubscribe ...


Customer Server Validation

  • Inherit from ValidationAttribute
  • CustomValidatorAttribute
  • Custom business logic (EntityErrorsException from BreezeController BeginSaveEntity)
  • [CustomValidation(typeof(UserValidationRules), "ValidateEmail")]
public static class UserValidationRules
{
public static ValidationResult ValidateEmail(string value, ValidationContext context)
{
if (!value.EndsWith("...") return new ValidationResult("...");
return ValidationResult.Success;
}
}

private bool BeforeSaveEntity(EntityInfo entityInfo)
{
User user = entityInfo.Entity as User;
if (user != null)
{
if (!user.CreditCard.StartsWith("5"))
{
throw new EntityErrorsException(new List<EntityErrors>)
{
new EntityError
{
EntityTypeName = user.GetType().ToString(),
ErrorMessage = "We only accept Mastercard",
PropertyName = "CreditCard",
ErrorName = "CreditCardValidationError",
KeyValues = new object[] { user.Id }
}
}
}
}
return true;
}