rails semi-complex STI with ancestry data model planning the routes and controllers
Asked Answered
H

4

7

I'm trying to figure out the best way to manage my controller(s) and models for a particular use case.

I'm building a review system where a User may build a review of several distinct types with a Polymorphic Reviewable.

Country (has_many reviews & cities)
Subdivision/State (optional, sometimes it doesnt exist,  also reviewable, has_many cities) 
City (has places & review)
Burrow (optional, also reviewable ex: Brooklyn)
Neighborhood (optional & reviewable, ex: williamsburg)
Place (belongs to city)

I'm also wondering about adding more complexity. I also want to include subdivisions occasionally... ie for the US, I might add Texas or for Germany, Baveria and have it be reviewable as well but not every country has regions and even those that do might never be reviewed. So it's not at all strict. I would like it to as simple and flexible as possible.

It'd kinda be nice if the user could just land on one form and select either a city or a country, and then drill down using data from say Foursquare to find a particular place in a city and make a review.

I'm really not sure which route I should take? For example, what happens if I have a Country, and a City... and then I decide to add a Burrow?

Could I give places tags (ie Williamsburg, Brooklyn) belong_to NY City and the tags belong to NY?

Tags are more flexible and optionally explain what areas they might be in, the tags belong to a city, but also have places and be reviewable?

So I'm looking for suggestions for anyone who's done something related.

Using Rails 3.2, and mongoid.

Hightower answered 22/3, 2012 at 14:58 Comment(1)
I didn't see anything in your question relating to STI but rather polymorphism.Azerbaijani
M
3

I've built something very similar and found two totally different way that both worked well.

Way 1: Country » Subcountry » City » Neighborhood

The first way that worked for me is to do it with Country, Subcountry, City, Neighborhood. This maps well to major geocoding services and is sufficient for most simple uses. This can be STI (as in your example) or with multiple tables (how I did it).

In your example you wrote "Subdivision/State". My two cents is to avoid using those terms and instead use "Subcountry" because it's an ISO standard and you'll save yourself some confusion when another developer thinks a subdivision is a tiny neighborhood of houses, or when you have a non-U.S. country that doesn't use states, but instead uses provinces.

This is what I settled on after many experiments with trying model names like Region, District, Area, Zone, etc. and abandoning these as too vague or too specific. In your STI case it may be fine to use more names.

One surprise is that it's a big help to write associations that go multi-level, for example to say country.cities (skipping subcountry). This is because sometimes the intermediary model doesn't exist (i.e. there's no subcountry). In your STI, this may be trickier.

Also you get a big speedup if you denormalize your tables, so for example my city table has a country field. This makes updating info a bit trickier but it's worth it. Your STI could inmplement an equivalent to this by using tags.

Way 2: Zones that are lists of lat/lng shapes with bounding boxes

The second way is to use an arbitrary Zone model and store latitude longitude shapes. This gives you enormous flexibility, and you can pre-calculate when shapes contain other shapes, or intersect them. So your "drill down" becomes "show me shapes immediately inside this one".

Postgres has some good geocoding helpers for this, and you can speed up lookups by doing bounding boxes of min/max lat/lng. We also stored data like the expected center point of a Zone (which is where we would drop a pin on a map), and a radius (useful for calculating queries like "show me all the x items within y distance).

With this approach we were able to do interesting zones like "Broadway in New York" which isn't really a neighborhood so much as long street, and "The Amazon Basin" which is defined by the river, not a country.

Monosymmetric answered 30/3, 2012 at 12:17 Comment(0)
A
2

STI Model with Ancestry and with Polymprphic Relation

I built something similar for previous projects, and went for STI with ancestry because it is very flexible and allows to model a tree of nodes. Not all intermediate nodes have to exist (as in your example of State/Subdivision/Subcountry).

For Mongoid there are at least two ancestry gems: mongoid-ancestry and mongestry (links below).

As an added benefit of using STI with ancestry, you can also model other location-related nodes, let's say restaurants or other places.

You can also add geo-location information lat/lon to all your nodes, so you can geo-tag them. In the example below I just used one set of geo-location coordinates (center point) - but you could of course also add several geo-locations to model a bounding box.

You can arrange the nodes in any order you like, e.g. through this_node.children.create(...) . When using MySQL with ancestry, you can pass-in the type of the newly created node. There must be a similar way with mongoid-ancestry (haven't tried it).

In addition to the tree-structured nodes, you can use a polymorphic collection to model the Reviews, and also Tags (well, there's a gem for acts_as_taggable, so you don't have to models Tags yourself).

Compared to modeling every class with it's own collection, this STI approach is much more flexible and keeps the schema simple. It's very easy to add a new type of node later.

This paradigm can be used with either Mongoid or SQL data stores.

# app/models/geo_node.rb

class GeoNode  # this is the parent class; geo_nodes is the table name / collection name.
  include Mongoid::Document
  has_ancestry   # either this
  has_mongestry  # or this
  has_many :reviews, :as => :reviewable

  field :lat, type: Float
  field :lon, type: Float

  field :name, type: String
  field :desc, type: String
  # ...
end

# app/models/geo_node/country.rb
class Country < GeoNode
end

# app/models/geo_node/subcountry.rb
Class Subcountry < GeoNode
end

# app/models/geo_node/city.rb
class City < GeoNode
end


# app/models/review.rb
class Review
  include Mongoid::Document
  belongs_to :reviewable, :polymorphic => true
  field :title
  field :details
end

Check these links:

A big thanks to Stefan Kroes for his awesome ancestry gem, and to Anton Orel for adapting it to Mongoid (mongoid-ancestry). ancestry is of the most useful gems I've seen.

Anastice answered 31/3, 2012 at 17:37 Comment(0)
S
1

Sounds like a good candidate for nested routes/resources. In routes.rb, do something like:

resources :cities do
  resources :reviews
end

resources :countries do
  resources :reviews
end

resources :places do
  resources :reviews
end

Which should produce something along the lines of rake routes:

   reviews_cities GET /cities/:id/reviews     {:controller=>"reviews", :action=>"index"}
reviews_countries GET /countries/:id/reviews  {:controller=>"reviews", :action=>"index"}
   reviews_places GET /countries/:id/reviews  {:controller=>"reviews", :action=>"index"}

...etc., etc.

In the controller action, you lookup match up the :id of reviewable record, and only send back reviews that are attached to that reviewable object.

Also, see the nested resources section of the Rails Routing Guide, and this RailsCast on Polymorphic relationships, which has a quick section on routing, and getting everything to line up properly.

Sera answered 22/3, 2012 at 15:6 Comment(5)
Sorry I don't think I posed my question clearly enough. Likely I will end up doing my routing as you suggest but, my question is more general. I updated it to try and be more clear. Thanks!Hightower
Re-read your question. For what it's worth, I wouldn't worry too much about planning your schema/app today for what you will only possibly be doing tomorrow. There's some benefit to planning ahead, if you know that you'll definitely be adding certain features later, but if it's still just a possibility, then there's less value in designing for "what if's". Instead, I would focus on what you're sure you'll need now, and refactoring when the time is right to take the app in new directions.Sera
Per @normalcity, I've spend 100+ hours cleaning up code written by others in the XP-style, "just-enough" to meet specs. To me this is a frustrating trend in development. We shouldn't over-engineer, but we shouldn't under-engineer either. This is where a very experienced developer can make a difference. Anybody can code, but experience tells you /what/ to code.Azerbaijani
Per @Azerbaijani - agreed. It's definitely a fine line. It's great advice to run your code past another developer to make sure they understand what you're trying to accomplish - or better yet, pair program with them. This will help you figure out where the line is between over and under engineer, because the other person's questions will often show flaws or weaknesses in your design. If you're both dedicated to doing what necessary and proper, with an eye toward delivering working software sooner than later, I think you'll get really close to walking that line.Sera
Also, if the other programmer you work with, as @Azerbaijani said, is more experienced than you, you'll be even more likely to succeed in doing what is both necessary, and proper, in your case, without under-engineering it.Sera
A
1

I would probably keep my data model very unrestrictive, and handle any specifics related to what filters to display in the controller/view. Make a mapping table where you can map attributes (i.e. city, borough, state, country) polymorphically, also polymorphically to reviewable.

By assuming many-to-many, your schema is as flexible as it can be, and you can restrict which mappings to create using validations or filters in your models.

It's basically using tagging, like you eluded, but not really using a tags model per-se, but rather a polymorphic association to different models that all act like tags.

Keep your DB schema clean and keep the business logic in ruby.

Azerbaijani answered 26/3, 2012 at 23:27 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.