dart 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +18 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +31 -0
  6. data/Rakefile +2 -0
  7. data/dart.gemspec +28 -0
  8. data/lib/dart.rb +1 -0
  9. data/lib/dart/active_record_model_reflection.rb +7 -0
  10. data/lib/dart/core.rb +17 -0
  11. data/lib/dart/core/association.rb +29 -0
  12. data/lib/dart/core/direct_association.rb +36 -0
  13. data/lib/dart/core/foreign_key_info.rb +8 -0
  14. data/lib/dart/core/many_to_many_association.rb +55 -0
  15. data/lib/dart/core/many_to_one_association.rb +18 -0
  16. data/lib/dart/core/one_to_many_association.rb +13 -0
  17. data/lib/dart/core/one_to_one_association.rb +17 -0
  18. data/lib/dart/core/relation.rb +46 -0
  19. data/lib/dart/database.rb +8 -0
  20. data/lib/dart/database/many_to_many_association.rb +14 -0
  21. data/lib/dart/database/many_to_one_association.rb +14 -0
  22. data/lib/dart/database/one_to_many_association.rb +14 -0
  23. data/lib/dart/database/relation.rb +21 -0
  24. data/lib/dart/database/test_helpers.rb +23 -0
  25. data/lib/dart/naming_conventions.rb +18 -0
  26. data/lib/dart/naming_conventions/abstract_base.rb +71 -0
  27. data/lib/dart/naming_conventions/association_helpers.rb +16 -0
  28. data/lib/dart/naming_conventions/direct_association_helpers.rb +21 -0
  29. data/lib/dart/naming_conventions/foreign_key_finder.rb +26 -0
  30. data/lib/dart/naming_conventions/many_to_many_association_helpers.rb +52 -0
  31. data/lib/dart/naming_conventions/many_to_one_association_helpers.rb +20 -0
  32. data/lib/dart/naming_conventions/one_to_many_association_helpers.rb +26 -0
  33. data/lib/dart/naming_conventions/relation_helpers.rb +40 -0
  34. data/lib/dart/reflection/abstract_resolver.rb +7 -0
  35. data/lib/dart/reflection/active_record_model/resolver.rb +122 -0
  36. data/lib/dart/reflection/orm_model_resolver.rb +64 -0
  37. data/lib/dart/reflection/sequel/naming_conventions.rb +25 -0
  38. data/lib/dart/reflection/sequel/sequelizer.rb +14 -0
  39. data/lib/dart/reflection/sequel_model/resolver.rb +78 -0
  40. data/lib/dart/reflection/sequel_table/foreign_key_finder.rb +50 -0
  41. data/lib/dart/reflection/sequel_table/reflector.rb +151 -0
  42. data/lib/dart/reflection/sequel_table/resolver.rb +60 -0
  43. data/lib/dart/reflection/sequel_table/schema.rb +52 -0
  44. data/lib/dart/sequel_model_reflection.rb +10 -0
  45. data/lib/dart/sequel_table_reflection.rb +26 -0
  46. data/lib/dart/version.rb +3 -0
  47. data/test/dart/database/many_to_many_association_test.rb +80 -0
  48. data/test/dart/naming_conventions/abstract_base_test.rb +38 -0
  49. data/test/dart/reflection/orm_model_resolver_test.rb +66 -0
  50. data/test/test_helper.rb +6 -0
  51. metadata +182 -0
@@ -0,0 +1,64 @@
1
+ module Dart
2
+ module Reflection
3
+ class OrmModelResolver < AbstractResolver
4
+
5
+ abstract_method :build_association, :has_column?, :reflection_from
6
+
7
+ attr_reader :this_model_class
8
+ private :this_model_class
9
+
10
+ def initialize(model_class)
11
+ @this_model_class = model_class
12
+ end
13
+
14
+ def build_from_association(association)
15
+ self.class.new(association.model_class)
16
+ end
17
+
18
+ # Returns the association with the given ass_name or nil if one does not exist
19
+ # @param [String] ass_name
20
+ # @return [Association]
21
+ def association_for(ass_name)
22
+ if ass_reflection = reflection_from(ass_name)
23
+ build_association(ass_reflection)
24
+ end
25
+ end
26
+
27
+ # Returns the column with the given col_name or nil if one does not exist
28
+ # @param [String] col_name
29
+ # @return [String]
30
+ def column_for(col_name)
31
+ col_name if has_column?(col_name)
32
+ end
33
+
34
+ def table_name
35
+ this_model_class.table_name.to_s
36
+ end
37
+
38
+ # Helpers
39
+
40
+
41
+ QUERY_OPTIONS = [:where, :order, :limit]
42
+ QUERY_OPTION_REGEXS = [/WHERE\s*(?<where>.*)/, /ORDER\s+BY\s*(?<order>.*)/, /LIMIT\s*(?<limit>.*)/]
43
+
44
+ # Returns parts of the given sql_string in a hash of the form {where: ..., order: ..., limit: ...}
45
+ def scope_hash_from(sql_string)
46
+ # TODO: cleanup - this is some of the ugliest code I've ever written
47
+ result = {}
48
+ re_start_pairs = QUERY_OPTION_REGEXS.map {|re| [re, sql_string =~ re]}.reject {|_, i| i.nil?}.sort_by {|_, i| i}
49
+ re_start_pairs.each_with_index do |re_start, i|
50
+ re, start_index = re_start
51
+ end_index = -1
52
+ if next_match = re_start_pairs[i+1]
53
+ end_index = next_match[1]-1
54
+ end
55
+ sql_string[start_index..end_index] =~ re
56
+ QUERY_OPTIONS.each {|o| result[o] = $~[o].strip if ($~[o] rescue nil)}
57
+ end
58
+
59
+ result
60
+ end
61
+
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,25 @@
1
+ require 'sequel'
2
+
3
+ module Dart
4
+ module Reflection
5
+ module Sequel
6
+ class NamingConventions < Dart::NamingConventions::AbstractBase
7
+
8
+ include ::Sequel::Inflections # for pluralize and singularize
9
+
10
+ def conventional_primary_key
11
+ 'id'
12
+ end
13
+
14
+ def foreign_key_regex
15
+ /_id$/
16
+ end
17
+
18
+ end
19
+ end
20
+
21
+ Dart::NamingConventions.instance = Sequel::NamingConventions.new
22
+ end
23
+ end
24
+
25
+
@@ -0,0 +1,14 @@
1
+ module Dart
2
+ module Reflection
3
+ module Sequel
4
+ module Sequelizer
5
+
6
+ # Converts the given identifier to the format needed by Sequel gem
7
+ def sequelize(id)
8
+ id.to_sym
9
+ end
10
+
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,78 @@
1
+ module Dart
2
+ module Reflection
3
+ module SequelModel
4
+ class Resolver < OrmModelResolver
5
+ include Sequel::Sequelizer
6
+
7
+ private
8
+
9
+ def reflection_from(ass_name)
10
+ # TODO assert this_model_class == ass_reflection[:model]
11
+ this_model_class.association_reflection(sequelize(ass_name))
12
+ end
13
+
14
+ def has_column?(col_name)
15
+ sequelized_col_name = sequelize(col_name)
16
+ this_model_class.columns.include?(sequelized_col_name)
17
+ end
18
+
19
+ ONE_TO_MANY_TYPE = :one_to_many
20
+ MANY_TO_ONE_TYPE = :many_to_one
21
+ MANY_TO_MANY_TYPE = :many_to_many
22
+
23
+ def build_association(ass_reflection)
24
+ associated_model_class = Module.const_get(ass_reflection[:class_name])
25
+
26
+ ass = case ass_reflection[:type]
27
+ when :one_to_one
28
+ OneToOneAssociation.new(child_table: associated_model_class.table_name, foreign_key: ass_reflection[:key],
29
+ parent_table: this_model_class.table_name, primary_key: ass_reflection.primary_key)
30
+
31
+ when :one_to_many
32
+ OneToManyAssociation.new(child_table: associated_model_class.table_name, foreign_key: ass_reflection[:key],
33
+ parent_table: this_model_class.table_name, primary_key: ass_reflection.primary_key)
34
+
35
+ when :many_to_one
36
+ ManyToOneAssociation.new(child_table: this_model_class.table_name, foreign_key: ass_reflection[:key],
37
+ parent_table: associated_model_class.table_name, primary_key: ass_reflection.primary_key)
38
+
39
+ when :many_to_many
40
+ left_ass = ManyToOneAssociation.new(child_table: ass_reflection[:join_table], foreign_key: ass_reflection[:left_key],
41
+ parent_table: this_model_class.table_name, primary_key: ass_reflection[:left_primary_key])
42
+ right_ass = ManyToOneAssociation.new(child_table: ass_reflection[:join_table], foreign_key: ass_reflection[:right_key],
43
+ parent_table: ass_reflection.associated_dataset.first_source, primary_key: ass_reflection.right_primary_key)
44
+ ManyToManyAssociation.new(left_ass, right_ass)
45
+
46
+ # TODO :one_through_one
47
+ # when :one_through_one
48
+
49
+ else
50
+ raise "don't yet know how to resolve associations of type '#{ass_reflection[:type]}' model=#{associated_model_class} association=#{ass_reflection[:name]}"
51
+ end
52
+
53
+ ass.model_class = associated_model_class
54
+
55
+ # ass.sql = ass_reflection.associated_dataset.sql
56
+ ass.scope = scope_for_association(ass_reflection)
57
+
58
+ ass.set_name!(ass_reflection[:name])
59
+ ass
60
+ end
61
+
62
+
63
+ def scope_for_association(ass_reflection)
64
+ dataset = ass_reflection.associated_dataset.qualify
65
+
66
+ if block = ass_reflection[:block]
67
+ dataset = dataset.instance_exec(dataset, &block)
68
+ end
69
+
70
+ opts = dataset.opts
71
+ result = QUERY_OPTIONS.map {|cond| [cond, opts[cond] && dataset.literal_append('', opts[cond])]}
72
+ Hash[result]
73
+ end
74
+
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,50 @@
1
+ module Dart
2
+ module Reflection
3
+ module SequelTable
4
+ class ForeignKeyFinder
5
+
6
+ # Returns the set of foreign keys on the given relation based on foreign key constraints defined in the db
7
+ # @param [Relation] relation the relation to search
8
+ # @param [Schema] schema defines the set of referenceable tables (and the db to search)
9
+ # @return [Array<ForeignKeyInfo>] info for foreign keys in the given table and db
10
+ #
11
+ def foreign_keys_for(relation, schema)
12
+ rows = schema.execute!(constraint_query(relation.table_name))
13
+ rows.all.map { |attrs| ForeignKeyInfo.from_hash(attrs) }
14
+ end
15
+
16
+ private
17
+
18
+
19
+ # @param [String|Symbol] table_name
20
+ def constraint_query(table_name)
21
+ # TODO incorporate namespaces
22
+
23
+ # TODO reduce coupling with ForeignKeyInfo and Association fields by using constants from that class e.g.
24
+ # "AS #{ForeignKeyInfo::CHILD_TABLE}"
25
+
26
+ <<-CONSTRAINT_QUERY_SQL
27
+ SELECT pg_constraint.conname AS constraint_name,
28
+ pg_class_con.relname AS child_table,
29
+ pg_attribute_con.attname AS foreign_key,
30
+ pg_class_ref.relname AS parent_table,
31
+ pg_attribute_ref.attname AS primary_key
32
+
33
+ FROM pg_constraint,
34
+ pg_class pg_class_con,
35
+ pg_class pg_class_ref,
36
+ pg_attribute
37
+ pg_attribute_con,
38
+ pg_attribute pg_attribute_ref
39
+
40
+ WHERE pg_class_con.relname = '#{table_name}' AND pg_constraint.contype = 'f' AND
41
+ pg_constraint.conrelid = pg_class_con.oid AND pg_attribute_con.attrelid = pg_constraint.conrelid AND pg_attribute_con.attnum = ANY (pg_constraint.conkey) AND
42
+ pg_constraint.confrelid = pg_class_ref.oid AND pg_attribute_ref.attrelid = pg_constraint.confrelid AND pg_attribute_ref.attnum = ANY (pg_constraint.confkey)
43
+
44
+ CONSTRAINT_QUERY_SQL
45
+ end
46
+
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,151 @@
1
+ module Dart
2
+ module Reflection
3
+ module SequelTable
4
+ class Reflector
5
+
6
+ attr_reader :db
7
+ private :db
8
+
9
+ # @param [String] database_url specifies database to reflect on
10
+ #
11
+ def initialize(database_url)
12
+ @db = ::Sequel.connect(database_url)
13
+ end
14
+
15
+ # @param [Hash] options
16
+ # @option options exclude_tables an Array or Regexp defining tables to exclude from the reflection
17
+ # @option options naming_conventions identify direct associations by column naming conventions (e.g. users.group_id => Group one_to_many users)
18
+ # @return [Array<Relation>]
19
+ #
20
+ def get_relations_for_code_gen(options={})
21
+ schema = get_schema(options)
22
+ create_join_associations_for_codegen!(schema, options)
23
+
24
+ schema.relations
25
+ end
26
+
27
+ # @param [Hash] options
28
+ # @option options exclude_tables an Array or Regexp defining tables to exclude from the reflection
29
+ # @option options naming_conventions identify direct associations by column naming conventions (e.g. users.group_id => Group one_to_many users)
30
+ # @return [Array<Relation>]
31
+ #
32
+ def get_schema_for_resolver(options={})
33
+ schema = get_schema(options)
34
+ create_join_associations_for_resolver!(schema, options)
35
+ schema
36
+ end
37
+
38
+ private
39
+
40
+ # @param [Hash] options
41
+ # @option options exclude_tables an Array or Regexp defining tables to exclude from the reflection
42
+ # @option options naming_conventions identify direct associations by column naming conventions (e.g. users.group_id => Group one_to_many users)
43
+ # @return [Array<Relation>]
44
+ #
45
+ def get_schema(options={})
46
+ exclude_tables = options.delete(:exclude_tables)
47
+ db_tables = case exclude_tables
48
+ when Enumerable
49
+ db.tables - exclude_tables.map(&:to_sym)
50
+ when Regexp
51
+ db.tables.reject { |t| exclude_tables.match(t) }
52
+ else # don't exclude any tables
53
+ db.tables
54
+ end
55
+
56
+ fk_finder = if options[:naming_conventions]
57
+ NamingConventions::ForeignKeyFinder.new
58
+ else
59
+ SequelTable::ForeignKeyFinder.new
60
+ end
61
+
62
+
63
+ schema = Schema.new(db, db_tables)
64
+ create_direct_associations!(schema, fk_finder)
65
+ schema
66
+ end
67
+
68
+ def create_direct_associations!(schema, foreign_key_finder)
69
+ schema.relations.each do |relation|
70
+ foreign_keys = foreign_key_finder.foreign_keys_for(relation, schema)
71
+
72
+ foreign_keys.each do |foreign_key|
73
+ if one_relation = schema.relation(foreign_key.parent_table)
74
+ relation.add_many_to_one foreign_key
75
+ one_relation.add_one_to_many foreign_key
76
+ else
77
+ fail "schema does not contain a table named '#{foreign_key.parent_table}'. Perhaps it was excluded accidentally?"
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+
84
+ # there are 2 ways to infer join association names from the database
85
+ # 1. by assigning short names and then disambiguating conflicts
86
+ # example: Group many_to_many :topics, join_table: :topic_assignments
87
+ # 2. by assigning longs names that don't conflict (either with direct associations or other joins)
88
+ # example: Group many_to_many :topic_assignment_topics, join_table: :topic_assignments
89
+ #
90
+ # neither method will correctly guess Group many_to_many :assigned_topics, join_table: :topic_assignments
91
+ #
92
+ # Method 1 is useful for generating code with suggested association names from an existing db.
93
+ # Method 2 is useful for resolving association names from the schema. However, a major drawback of 2 is it will
94
+ # almost always guess wrong for conventional joins, for example, by generating
95
+ # Group many_to_many :groups_users_users, join_table: groups_users
96
+ # instead of
97
+ # Group many_to_many :users, join_table: groups_users
98
+ #
99
+ # Method 2 could be 'improved' by a strictish option that uses long names except in the case where a
100
+ # conventional join table name is used. The -ish means any combination of 'groups_users', 'group_users',
101
+ # 'group_user', 'user_groups', etc. is allowed. This can lead to conflicts if there were multiple join tables
102
+ # that meet the criteria of -ish.
103
+ #
104
+ # Or better yet ...
105
+ #
106
+ # A resolver could start with some association name, e.g. groups.users and, seeing that there is no direct
107
+ # association, then look for all possible join tables (i.e. those with parent associations to both users and
108
+ # groups) and choose the best one (i.e. the one named groups_users, or users_groups, or ...)
109
+ #
110
+ # For now, they are one and the same ...
111
+ #
112
+ def create_join_associations_for_codegen!(schema, options={})
113
+ create_join_associations!(schema, options)
114
+ end
115
+
116
+ def create_join_associations_for_resolver!(schema, options={})
117
+ create_join_associations!(schema, options)
118
+ end
119
+
120
+ def create_join_associations!(schema, options={})
121
+ schema.relations.each do |relation|
122
+ relation.possible_join_pairs.each do |ass1, ass2|
123
+ r1, r2 = schema[ass1.parent_table], schema[ass2.parent_table]
124
+
125
+ # skip if either relation already has a direct, conventional association to the other
126
+ # e.g. don't users many_to_many groups if users has group_id
127
+ # e.g do users many_to_many groups if users has reporting_group_id (many_to_one reporting_group, and many_to_many groups)
128
+ next if r1.has_direct_conventional_parent?(r2.table_name) || r2.has_direct_conventional_parent?(r1.table_name)
129
+
130
+ r1.add_many_to_many ass1, ass2
131
+ r2.add_many_to_many ass2, ass1
132
+ end
133
+ end
134
+
135
+ # By default joins are given short names. When multiple joins conflict on name, we ask the relation to
136
+ # disambiguate them
137
+ schema.relations.each(&:disambiguate_conflicting_join_names!)
138
+
139
+ schema.relations.each do |relation|
140
+ relation.duplicate_join_association_names.each do |k, v|
141
+ puts "AFTER DISAMBIGUATION #{relation} has #{v.count} m2m associations named #{k}"
142
+ end
143
+ end
144
+ end
145
+
146
+ end
147
+ end
148
+ end
149
+ end
150
+
151
+
@@ -0,0 +1,60 @@
1
+ module Dart
2
+ module Reflection
3
+ module SequelTable
4
+ class Resolver < AbstractResolver
5
+
6
+ attr_reader :relation
7
+ private :relation
8
+
9
+ def initialize(table_name)
10
+ @table_name = table_name
11
+ @relation = TheSchema.instance.relation_for(table_name)
12
+ end
13
+
14
+ def build_from_association(association)
15
+ self.class.new(association.associated_table)
16
+ end
17
+
18
+ # Returns the association with the given ass_name or nil if one does not exist
19
+ # @param [String] ass_name
20
+ # @return [Association]
21
+ #
22
+ def association_for(ass_name)
23
+ ass = relation.all_associations.detect { |ass| ass.name == ass_name }
24
+ ass.scope = {} # no scope can be determined by SQL reflection
25
+ ass
26
+ end
27
+
28
+ # Returns the column with the given col_name or nil if one does not exist
29
+ # @param [String] col_name
30
+ # @return [String]
31
+ def column_for(col_name)
32
+ relation.column_names.detect { |col| col == col_name }
33
+ end
34
+
35
+ def table_name
36
+ @table_name
37
+ end
38
+
39
+ private
40
+
41
+ class TheSchema
42
+ include Singleton
43
+
44
+ def relation_for(table_name)
45
+ schema[table_name] or raise "no relation for '#{table_name}' was found in the schema"
46
+ end
47
+
48
+ def schema
49
+ # Dart::Reflection::SequelTable::Reflector.new('postgres://smcc@localhost:5432/iapps_development').get_associations(:groups, naming_conventions: true) # setting naming_conventions to true might cause simple ass names to become more complex
50
+ @schema ||= begin
51
+ # TODO Benchmark.realtime
52
+ Reflector.new('postgres://smcc@localhost:5432/iapps_development').get_schema_for_resolver(exclude_tables: /migration/)
53
+ end
54
+ end
55
+ end
56
+
57
+ end
58
+ end
59
+ end
60
+ end