dart 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +31 -0
- data/Rakefile +2 -0
- data/dart.gemspec +28 -0
- data/lib/dart.rb +1 -0
- data/lib/dart/active_record_model_reflection.rb +7 -0
- data/lib/dart/core.rb +17 -0
- data/lib/dart/core/association.rb +29 -0
- data/lib/dart/core/direct_association.rb +36 -0
- data/lib/dart/core/foreign_key_info.rb +8 -0
- data/lib/dart/core/many_to_many_association.rb +55 -0
- data/lib/dart/core/many_to_one_association.rb +18 -0
- data/lib/dart/core/one_to_many_association.rb +13 -0
- data/lib/dart/core/one_to_one_association.rb +17 -0
- data/lib/dart/core/relation.rb +46 -0
- data/lib/dart/database.rb +8 -0
- data/lib/dart/database/many_to_many_association.rb +14 -0
- data/lib/dart/database/many_to_one_association.rb +14 -0
- data/lib/dart/database/one_to_many_association.rb +14 -0
- data/lib/dart/database/relation.rb +21 -0
- data/lib/dart/database/test_helpers.rb +23 -0
- data/lib/dart/naming_conventions.rb +18 -0
- data/lib/dart/naming_conventions/abstract_base.rb +71 -0
- data/lib/dart/naming_conventions/association_helpers.rb +16 -0
- data/lib/dart/naming_conventions/direct_association_helpers.rb +21 -0
- data/lib/dart/naming_conventions/foreign_key_finder.rb +26 -0
- data/lib/dart/naming_conventions/many_to_many_association_helpers.rb +52 -0
- data/lib/dart/naming_conventions/many_to_one_association_helpers.rb +20 -0
- data/lib/dart/naming_conventions/one_to_many_association_helpers.rb +26 -0
- data/lib/dart/naming_conventions/relation_helpers.rb +40 -0
- data/lib/dart/reflection/abstract_resolver.rb +7 -0
- data/lib/dart/reflection/active_record_model/resolver.rb +122 -0
- data/lib/dart/reflection/orm_model_resolver.rb +64 -0
- data/lib/dart/reflection/sequel/naming_conventions.rb +25 -0
- data/lib/dart/reflection/sequel/sequelizer.rb +14 -0
- data/lib/dart/reflection/sequel_model/resolver.rb +78 -0
- data/lib/dart/reflection/sequel_table/foreign_key_finder.rb +50 -0
- data/lib/dart/reflection/sequel_table/reflector.rb +151 -0
- data/lib/dart/reflection/sequel_table/resolver.rb +60 -0
- data/lib/dart/reflection/sequel_table/schema.rb +52 -0
- data/lib/dart/sequel_model_reflection.rb +10 -0
- data/lib/dart/sequel_table_reflection.rb +26 -0
- data/lib/dart/version.rb +3 -0
- data/test/dart/database/many_to_many_association_test.rb +80 -0
- data/test/dart/naming_conventions/abstract_base_test.rb +38 -0
- data/test/dart/reflection/orm_model_resolver_test.rb +66 -0
- data/test/test_helper.rb +6 -0
- 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,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
|