diff --git a/.gitignore b/.gitignore index 73633ee..1b861d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ pkg/** +.*.swp smql.gemspec diff --git a/Rakefile b/Rakefile index 4245ce4..c9ac2a2 100644 --- a/Rakefile +++ b/Rakefile @@ -15,6 +15,7 @@ begin gem.add_dependency 'activerecord' gem.add_dependency 'activesupport' gem.add_dependency 'json' + gem.add_dependency 'methodphitamine' end Jeweler::GemcutterTasks.new rescue LoadError diff --git a/VERSION b/VERSION index 4e379d2..2ec68a9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.2 +0.0.4.3 diff --git a/lib/smql_to_ar.rb b/lib/smql_to_ar.rb index d144320..6d252af 100644 --- a/lib/smql_to_ar.rb +++ b/lib/smql_to_ar.rb @@ -15,6 +15,18 @@ # along with this program. If not, see . class SmqlToAR + module Assertion + def raise_unless cond, exception = nil, *args + cond, exception, *args = yield. cond, exception, *args if block_given? + raise exception || Exception, *args unless cond + end + + def raise_if cond, exception = nil, *args + cond, exception, *args = yield. cond, exception, *args if block_given? + raise exception || Exception, *args if cond + end + end + include ActiveSupport::Benchmarkable ############################################################################r # Exceptions @@ -77,19 +89,27 @@ class SmqlToAR end end - class OnlyOrderOnBaseError < SMQLError + class RootOnlyFunctionError < SMQLError def initialize path super :path => path end end + class ConColumnError < SMQLError + def initialize expected, got + super :expected => expected, :got => got + end + end + class BuilderError < Exception; end ############################################################################# # Model der Relation `rel` von `model` def self.model_of model, rel - model.reflections[ rel.to_sym].andand.klass + rel = rel.to_sym + r = model.reflections[ rel].andand.klass + r.nil? && :self == rel ? model : r end # Eine Spalte in einer Tabelle, relativ zu `Column#model`. @@ -103,7 +123,7 @@ class SmqlToAR def initialize model, *col @model = model @last_model = nil - *@path, @col = Array.wrap( col).collect {|s| s.to_s.split /[.\/]/ }.flatten.collect &:to_sym + *@path, @col = *Array.wrap( col).collect( &it.to_s.split( /[.\/]/)).flatten.collect( &:to_sym).reject( &it==:self) end def last_model @@ -113,9 +133,12 @@ class SmqlToAR def each model = @model @path.each do |rel| - model = SmqlToAR.model_of model, rel - return false unless model - yield rel, model + rel = rel.to_sym + unless :self == rel + model = SmqlToAR.model_of model, rel + return false unless model + yield rel, model + end end model end @@ -140,19 +163,23 @@ class SmqlToAR def joins builder = nil, table = nil, &exe pp = [] table = Array.wrap table - exe ||= builder ? lambda {|j, m| builder.join table+j, m} : Array.method( :[]) + exe ||= builder ? lambda {|j, m| builder.joins table+j, m} : Array.method( :[]) collect do |rel, model| pp.push rel exe.call pp, model end end - def to_a() @path+[@col] end + def self?() !@col end + def length() @path.length+(self.self? ? 0 : 1) end + def size() @path.size+(self.self? ? 0 : 1) end + def to_a() @path+(self.self? ? [] : [@col]) end def to_s() to_a.join '.' end def to_sym() to_s.to_sym end def to_json() to_s end def inspect() "#" end - def relation() SmqlToAR.model_of last_model, @col end + def relation() self.self? ? model : SmqlToAR.model_of( last_model, @col) end def allowed?() ! self.protected? end + def child?() @path.empty? and !!relation end end attr_reader :model, :query, :conditions, :builder, :order @@ -179,6 +206,23 @@ class SmqlToAR #p model: @model, query: @query end + def self.models models + models = Array.wrap models + r = Hash.new {|h,k| h[k] = {} } + while model = models.tap( &:uniq!).pop + refls = model.respond_to?( :reflections) && model.reflections + refls && refls.each do |name, refl| + r[model.name][name] = case refl + when ActiveRecord::Reflection::ThroughReflection then {:macro => refl.macro, :model => refl.klass.name, :through => refl.through_reflection.name} + when ActiveRecord::Reflection::AssociationReflection then {:macro => refl.macro, :model => refl.klass.name} + else raise "Ups: #{refl.class}" + end + models.push refl.klass unless r.keys.include? refl.klass.name + end + end + r + end + def parse #benchmark 'SMQL parse' do @conditions = ConditionTypes.try_parse @model, @query @@ -214,4 +258,12 @@ class SmqlToAR def self.to_ar *params new( *params).to_ar end + + def self.reload_library + lib_dir = File.dirname __FILE__ + fj = lambda {|*a| File.join lib_dir, *a } + load fj.call( 'smql_to_ar.rb') + load fj.call( 'smql_to_ar', 'condition_types.rb') + load fj.call( 'smql_to_ar', 'query_builder.rb') + end end diff --git a/lib/smql_to_ar/condition_types.rb b/lib/smql_to_ar/condition_types.rb index 84fb850..05ce5c6 100644 --- a/lib/smql_to_ar/condition_types.rb +++ b/lib/smql_to_ar/condition_types.rb @@ -25,28 +25,44 @@ class SmqlToAR # Nimmt eine Klasse ein Objekt an, so soll diese Klasse instanziert werden. # Alles weitere siehe Condition. module ConditionTypes + extend SmqlToAR::Assertion + class < [:givenname, :surname, :nick] def split_keys k k.split( '|').collect &:to_sym end + def conditions &e + unless block_given? + r = Enumerator.new( self, :conditions) + s = self + r.define_singleton_method :[] do |k| + s.conditions.select {|c| c::Operator === k } + end + return r + end + constants.each do |c| + next if :Condition == c + c = const_get c + next if Condition === c + yield c + end + end + # Eine Regel parsen. # Ex: Person, "givenname=", "Peter" def try_parse_it model, colop, val r = nil #p :try_parse => { :model => model, :colop => colop, :value => val } - constants.each do |c| - next if :Condition == c - c = const_get c - next if Condition === c - raise UnexpectedColOpError.new( model, colop, val) unless colop =~ /^(?:\d*:)?(.*?)(\W*)$/ + conditions.each do |c| + raise_unless colop =~ /^(?:\d*:)?(.*?)(\W*)$/, UnexpectedColOpError.new( model, colop, val) col, op = $1, $2 col = split_keys( col).collect {|c| Column.new model, c } r = c.try_parse model, col, op, val break if r end - raise UnexpectedError.new( model, colop, val) unless r + raise_unless r, UnexpectedError.new( model, colop, val) r end @@ -72,16 +88,24 @@ class SmqlToAR end class Condition + include SmqlToAR::Assertion + extend SmqlToAR::Assertion attr_reader :value, :cols Operator = nil Expected = [] Where = nil - # Versuche das Objekt zu erkennen. Operator und Expected muessen passen. - # Passt das Object, die Klasse instanzieren. - def self.try_parse model, cols, op, val - #p :self => name, :try_parse => op, :cols => cols, :with => self::Operator, :value => val, :expected => self::Expected, :model => model.name - new model, cols, val if self::Operator === op and self::Expected.any? {|i| i === val } + class < name, :try_parse => op, :cols => cols, :with => self::Operator, :value => val, :expected => self::Expected, :model => model.name + new model, cols, val if self::Operator === op and self::Expected.any?( &it === val) + end + + def inspect + "#{self.name}(:operator=>#{self::Operator.inspect}, :expected=>#{self::Expected.inspect}, :where=>#{self::Where.inspect})" + end end def initialize model, cols, val @@ -93,6 +117,10 @@ class SmqlToAR verify end + def inspect + "#<#{self.class.name}:0x#{(self.object_id<<1).to_s 16} model: #{self.class.name}, cols: #{@cols.inspect}, value: #{@value.inspect}>" + end + def verify @cols.each do |col| verify_column col @@ -103,19 +131,19 @@ class SmqlToAR # Gibt es eine Spalte diesen Namens? # Oder: Gibt es eine Relation diesen Namens? (Hier nicht der Fall) def verify_column col - raise NonExistingColumnError.new( %w[Column], col) unless col.exist_in? + raise_unless col.exist_in?, NonExistingColumnError.new( %w[Column], col) end # Modelle koennen Spalten/Relationen verbieten mit Model#smql_protected. # Dieses muss ein Object mit #include?( name_als_string) zurueckliefern, # welches true fuer verboten und false fuer, erlaubt steht. def verify_allowed col - raise ProtectedColumnError.new( col) if col.protected? + raise_if col.protected?, ProtectedColumnError.new( col) end # Erstelle alle noetigen Klauseln. builder nimmt diese entgegen, - # wobei builder.join, builder.select, builder.where und builder.wobs von interesse sind. - # mehrere Schluessel bedeuten, dass die Values _alle_ zutreffen muessen, wobei die Schluessel geodert werden. + # wobei builder.joins, builder.select, builder.where und builder.wobs von interesse sind. + # mehrere Schluessel bedeuten, dass die Values _alle_ zutreffen muessen, wobei die Schluessel geODERt werden. # Ex: # 1) {"givenname=", "Peter"} #=> givenname = 'Peter' # 2) {"givenname=", ["Peter", "Hans"]} #=> ( givenname = 'Peter' OR givenname = 'Hans' ) @@ -128,15 +156,16 @@ class SmqlToAR @cols.each do |col| col.joins builder, table col = builder.column table+col.path, col.col - builder.where *values.keys.collect {|vid| self.class::Where % [ col, vid.to_s ] } + builder.where values.keys.collect {|vid| self.class::Where % [ col, vid.to_s ] } end else + b2 = SmqlToAR::And.new builder values.keys.each do |vid| - builder.where *@cols.collect {|col| + b2.where SmqlToAR::Or[ *@cols.collect {|col| col.joins builder, table col = builder.column table+col.path, col.col self.class::Where % [ col, vid.to_s ] - } + }] end end self @@ -185,50 +214,94 @@ class SmqlToAR In = simple_condition NotIn, '|=', '%s IN (%s)', [Array] In2 = simple_condition In, '', nil, [Array] - NotEqual = simple_condition Condition, /\!=|<>/, "%s <> %s", [Array, String, Numeric] + NotEqual = simple_condition Condition, '!=', "%s <> %s", [Array, String, Numeric] + NotEqual2 = simple_condition Condition, '<>', "%s <> %s", [Array, String, Numeric] GreaterThanOrEqual = simple_condition Condition, '>=', "%s >= %s", [Array, Numeric] LesserThanOrEqual = simple_condition Condition, '<=', "%s <= %s", [Array, Numeric] + + # Examples: + # { 'articles=>' => { id: 1 } } + # { 'articles=>' => [ { id: 1 }, { id: 2 } ] } class EqualJoin @model.reflections.keys - raise NonExistingRelationError.new( %w[Relation], col) unless refl + raise_unless col.relation, NonExistingRelationError.new( %w[Relation], col) end def build builder, table @cols.each do |col, sub| + model, *sub = sub t = table + col.path + [col.col] - #p sub: sub - #p col: col, joins: col.joins - col.joins.each {|j, m| builder.join table+j, m } - builder.join t, SmqlToAR.model_of( col.last_model, col.col) - sub[1..-1].each {|one| one.build builder, t } + col.joins.each {|j, m| builder.joins table+j, m } + builder.joins t, model + b2 = 1 == sub.length ? builder : Or.new( builder) + sub.each {|i| i.collect( &it.build( And.new( b2), t)); p 'or' => b2 } end self end end + + # Takes to Queries. + # First Query will be a Subquery, second a regular query. + # Example: + # Person.smql 'sub.articles:' => [{'limit:' => 1, 'order:': 'updated_at desc'}, {'content~' => 'some text'}] + # Person must have as last Article (compared by updated_at) owned by Person a Artive which has 'some text' in content. + # The last Article needn't to have 'some text' has content, the subquery takes it anyway. + # But the second query compares to it and never to any other Article, because these are filtered by first query. + # The difference to + # Person.smql :articles => {'content~' => 'some text', 'limit:' => 1, 'order:': 'updated_at desc'} + # is, second is not allowed (limit and order must be in root) and this means something like + # "Person must have the Article owned by Person which has 'some text' in content. + # limit and order has no function in this query and this article needn't to be the last." +=begin + class SubEqualJoin < EqualJoin + Operator = '()' + Expected = [lambda {|x| x.kind_of?( Array) and (1..2).include?( x.length) and x.all?( &it.kind_of?( Hash))}] + + def initialize model, cols, val + super model, cols, val[1] + # sub: model, subquery, sub(condition) + @cols.each {|col, sub| sub[ 1..-1] = SmqlToAR.new( col.relation, val[0]).parse, *sub[-1] } + end + + def verify_column col + raise_unless col.child?, ConColumnError.new( [:Column], col) + end + + def build builder, table + @cols.each do |col, sub| + t = table+col.to_a + builder.sub_joins t, col, *sub[0..1] + #ap sub: sub[2..-1] + sub[2..-1].each &it.build( builder, t) + end + self + end + end +=end + Equal = simple_condition Condition, '=', "%s = %s", [Array, String, Numeric] Equal2 = simple_condition Equal, '', "%s = %s", [String, Numeric] GreaterThan = simple_condition Condition, '>', "%s > %s", [Array, Numeric] LesserThan = simple_condition Condition, '<', "%s < %s", [Array, Numeric] NotIlike = simple_condition Condition, '!~', "%s NOT ILIKE %s", [Array, String] Ilike = simple_condition Condition, '~', "%s ILIKE %s", [Array, String] + Exists = simple_condition Condition, '', '%s IS NOT NULL', [TrueClass] + NotExists = simple_condition Condition, '', '%s IS NULL', [FalseClass] - ####### No Operator ####### Join = simple_condition EqualJoin, '', nil, [Hash] InRange2 = simple_condition InRange, '', nil, [Range] class Select < Condition @@ -236,7 +309,7 @@ class SmqlToAR Expected = [nil] def verify_column col - raise NonExistingSelectableError.new( col) unless col.exist_in? or SmqlToAR.model_of( col.last_model, col.col) + raise_unless col.exist_in? || SmqlToAR.model_of( col.last_model, col.col), NonExistingSelectableError.new( col) end def build builder, table @@ -258,13 +331,19 @@ class SmqlToAR Expected = [String, Array, Hash, Numeric, nil] class Function + include SmqlToAR::Assertion Name = nil Expected = [] attr_reader :model, :func, :args - def self.try_parse model, func, args - #SmqlToAR.logger.info( { try_parse: [func,args]}.inspect) - self.new model, func, args if self::Name === func and self::Expected.any? {|e| e === args } + class <#{self::Name}, :expected=>#{self::Expected})" + end end def initialize model, func, args @@ -277,22 +356,19 @@ class SmqlToAR Expected = [String, Array, Hash, nil] def initialize model, func, args - #SmqlToAR.logger.info( {args: args}.inspect) args = case args when String then [args] when Array, Hash then args.to_a when nil then nil else raise 'Oops' end - #SmqlToAR.logger.info( {args: args}.inspect) args.andand.collect! do |o| o = Array.wrap o col = Column.new model, o.first o = 'desc' == o.last.to_s.downcase ? :DESC : :ASC - raise NonExistingColumnError.new( [:Column], col) unless col.exist_in? + raise_unless col.exist_in?, NonExistingColumnError.new( [:Column], col) [col, o] end - #SmqlToAR.logger.info( {args: args}.inspect) super model, func, args end @@ -302,22 +378,39 @@ class SmqlToAR col, o = o col.joins builder, table t = table + col.path - #raise OnlyOrderOnBaseError.new( t) unless 1 == t.length + #raise_unless 1 == t.length, RootOnlyFunctionError.new( t) builder.order t, col.col, o end end end + class Limit < Function + Name = :limit + Expected = [Fixnum] + + def build builder, table + raise_unless 1 == table.length, RootOnlyFunctionError.new( table) + builder.limit = Array.wrap(@args).first.to_i + end + end + + class Offset < Function + Name = :offset + Expected = [Fixnum] + + def build builder, table + raise_unless 1 == table.length, RootOnlyFunctionError.new( table) + builder.offset = Array.wrap(@args).first.to_i + end + end + def self.new model, col, val - #SmqlToAR.logger.info( { function: col.first.to_sym }.inspect) r = nil constants.each do |c| next if [:Function, :Where, :Expected, :Operator].include? c c = const_get c next if Function === c or not c.respond_to?( :try_parse) - #SmqlToAR.logger.info( {f: c}.inspect) r = c.try_parse model, col.first.to_sym, val - #SmqlToAR.logger.info( {r: r}.inspect) break if r end r diff --git a/lib/smql_to_ar/query_builder.rb b/lib/smql_to_ar/query_builder.rb index dc3bd4e..ff3980d 100644 --- a/lib/smql_to_ar/query_builder.rb +++ b/lib/smql_to_ar/query_builder.rb @@ -22,36 +22,41 @@ class SmqlToAR class Vid attr_reader :vid def initialize( vid) @vid = vid end - def to_s() ":c#{@vid}" end - def to_sym() "c#{@vid}".to_sym end + def to_s() ":smql_c#{@vid}" end + def to_sym() "smql_c#{@vid}".to_sym end alias sym to_sym def to_i() @vid end end - attr_reader :table_alias, :model, :table_model, :base_table, :_where, :_select, :_wobs, :_joins - attr_accessor :logger + attr_reader :table_alias, :model, :table_model, :base_table, :_where, :_select, :_wobs, :_joins, :prefix, :_vid + attr_accessor :logger, :limit, :offset - def initialize model + def initialize model, prefix = nil + @prefix = "smql" @logger = SmqlToAR.logger @table_alias = Hash.new do |h, k| - k = Array.wrap k - h[k] = "smql,#{k.join(',')}" + j = Array.wrap( k).compact + h[k] = h.key?(j) ? h[j] : "#{@prefix},#{j.join(',')}" end - @_vid, @_where, @_wobs, @model, @quoter = 0, [], {}, model, model.connection + @_vid, @_where, @_wobs, @model, @quoter = 0, SmqlToAR::And[], {}, model, model.connection @base_table = [model.table_name.to_sym] @table_alias[ @base_table] = @base_table.first t = quote_table_name @table_alias[ @base_table] - @_select, @_joins, @_joined, @_includes, @_order = ["DISTINCT #{t}.*"], "", [], [], [] + @_select, @_joins, @_joined, @_includes, @_order = ["DISTINCT #{t}.*"], "", [@base_table], [], [] @table_model = {@base_table => @model} end def vid() Vid.new( @_vid+=1) end + def inspect + "#<#{self.class.name}:#{"0x%x"% (self.object_id<<1)}|#{@prefix}:#{@base_table}:#{@model} vid=#{@_vid} where=#{@_where} wobs=#{@_wobs} select=#{@_select} aliases=#{@_table_alias}>" + end + # Jede via where uebergebene Condition wird geodert und alle zusammen werden geundet. # "Konjunktive Normalform". Allerdings duerfen Conditions auch Komplexe Abfragen enthalten. - # Ex: builder.where( 'a = a', 'b = c').where( 'c = d', 'e = e').where( 'x = y').where( '( m = n AND o = p )', 'f = g') + # Ex: builder.where( ['a = a', 'b = c']).where( ['c = d', 'e = e']).where( 'x = y').where( ['( m = n AND o = p )', 'f = g']) # #=> WHERE ( a = a OR b = c ) AND ( c = d OR e = e ) AND x = y ( ( m = n AND o = p ) OR f = g ) - def where *cond + def where cond @_where.push cond self end @@ -74,32 +79,54 @@ class SmqlToAR end def build_join orig, pretable, table, prekey, key - " JOIN #{quote_table_name orig.to_sym} AS #{quote_table_name table} ON #{column pretable, prekey} = #{column table, key} " + " LEFT JOIN #{orig} AS #{quote_table_name table} ON #{column pretable, prekey} = #{column table, key} " end - def join table, model - return self if @_joined.include? table # Already joined - pretable = table[0...-1] + def sub_joins table, col, model, query + prefix, base_table = "#{@prefix}_sub", col.relation.table_name + join_ table, model, "(#{query.build( prefix).ar.to_sql})" + end + + def join_ table, model, query, pretable = nil + pretable ||= table[0...-1] @table_model[ table] = model premodel = @table_model[ pretable] t = @table_alias[ table] pt = quote_table_name @table_alias[ table[ 0...-1]] refl = premodel.reflections[table.last] - case refl.macro - when :has_many - @_joins += build_join model.table_name, pretable, t, premodel.primary_key, refl.primary_key_name - when :belongs_to - @_joins += build_join model.table_name, pretable, t, refl.primary_key_name, premodel.primary_key - when :has_and_belongs_to_many - jointable = [','] + table - @_joins += build_join refl.options[:join_table], pretable, @table_alias[jointable], premodel.primary_key, refl.primary_key_name - @_joins += build_join model.table_name, jointable, t, refl.association_foreign_key, refl.association_primary_key - else raise BuilderError, "Unkown reflection macro: #{refl.macro.inspect}" + case refl + when ActiveRecord::Reflection::ThroughReflection + through = refl.through_reflection + throughtable = table[0...-1]+[through.name.to_sym] + srctable = throughtable+[refl.source_reflection.name] + @table_model[ srctable] = model + @table_alias[ table] = @table_alias[ srctable] + join_ throughtable, through.klass, quote_table_name( through.table_name) + join_ srctable, refl.klass, query, throughtable + when ActiveRecord::Reflection::AssociationReflection + case refl.macro + when :has_many, :has_one + @_joins += build_join query, pretable, t, premodel.primary_key, refl.primary_key_name + when :belongs_to + @_joins += build_join query, pretable, t, refl.primary_key_name, premodel.primary_key + when :has_and_belongs_to_many + jointable = [','] + table + @_joins += build_join refl.options[:join_table], pretable, @table_alias[jointable], premodel.primary_key, refl.primary_key_name + @_joins += build_join query, jointable, t, refl.association_foreign_key, refl.association_primary_key + else raise BuilderError, "Unkown reflection macro: #{refl.macro.inspect}" + end + else raise BuilderError, "Unkown reflection type: #{refl.class.name}" end - @_joined.push table self end + def joins table, model + table = table.flatten.compact + return self if @_joined.include? table # Already joined + join_ table, model, quote_table_name( model.table_name) + @_joined.push table + end + def includes table @_includes.push table self @@ -117,30 +144,22 @@ class SmqlToAR self end - class Dummy - def method_missing m, *a, &e - #p :dummy => m, :pars => a, :block => e - self - end - end - def build_ar - where_str = @_where.collect do |w| - w = Array.wrap w - 1 == w.length ? w.first : "( #{w.join( ' OR ')} )" - end.join ' AND ' + where_str = @_where.type_correction!.optimize!.build_where incls = {} @_includes.each do |inc| b = incls inc[1..-1].collect {|rel| b = b[rel] ||= {} } end - @logger.debug incls: incls, joins: @_joins @model = @model. select( @_select.join( ', ')). joins( @_joins). where( where_str, @_wobs). order( @_order.join( ', ')). includes( incls) + @model = @model.limit @limit if @limit + @model = @model.offset @offset if @offset + @model end def fix_calculate @@ -159,4 +178,72 @@ class SmqlToAR @model end end + + class SubBuilder < Array + attr_reader :parent, :_where + delegate :wobs, :joins, :includes, :sub_joins, :vid, :quote_column_name, :quoter, :quote_table_name, :column, :to => :parent + + def initialize parent, tmp = false + @parent = parent + @parent.where self unless @parend.nil? && tmp + end + + def new parent, tmp = false + super parent, tmp + #return parent if self.class == parent.class + #super parent + end + + alias where push + + def type_correction! + collect! do |sub| + if sub.kind_of? Array + sub = default[ *sub] unless sub.respond_to?( :type_correction!) + sub.type_correction! + end + sub + end + self + end + + def optimize! + ext = [] + collect! do |sub| + sub = sub.optimize! if sub.kind_of? Array + if self.class == sub.class + ext.push *sub + nil + elsif sub.blank? + nil + else + sub + end + end.compact! + push *ext + self + end + + def inspect + "#{self.class.name.sub( /.*::/, '')}[ #{collect(&:inspect).join ', '}]" + end + def default() SmqlToAR::And end + def default_new( parent) default.new self, parent, false end + def collect_build_where + collect {|x| x.respond_to?( :build_where) ? x.build_where : x.to_s } + end + end + + class And < SubBuilder + def default; SmqlToAR::Or; end + def build_where + collect_build_where.join ' AND ' + end + end + + class Or < SubBuilder + def build_where + collect_build_where.join ' OR ' + end + end end