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.
- 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
|