dart 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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