Pertama, artikel ini bukan tentang bagaimana saya suka kereta api, dan kedua, artikel ini bukan tentang bagaimana saya membenci mereka. Mereka dapat diperlakukan dengan cara yang sangat berbeda dan mereka akan menjadi lebih baik hanya jika Anda mengubahnya. Dan mereka bisa menjadi lebih buruk hanya jika mereka mulai berubah. Secara umum, saya memperingatkan Anda, tetapi Anda mengerti saya.
Salah satu konsep utama ActiveRecord adalah bahwa databasenya cukup utilitarian dan dapat diubah. Nah, Anda duduk di sana, menulis model Anda menggunakan MySQL, dan tiba-tiba Anda membaca di suatu tempat bahwa Anda dapat mengambil MySQL dan menggantinya dengan MongoDB. Yah, tidak terlalu radikal, tetapi, katakanlah, di PostgreSQL, Anda mungkin punya alasan untuk mengganti MySQL. Atau sebaliknya, saya tidak menentang MySQL. Di sini ActiveRecord mengklaim untuk memudahkan Anda, yang seharusnya mencakup, sebelum / sesudah filter dan asosiasi cukup abstrak untuk tidak perlu khawatir tentang menghasilkan kueri basis data dan mengurus logika aplikasi. Bahwa alih-alih WHERE is_archived = TRUE
Anda senang menulis di where(is_archived: true)
dan ActiveRecord akan melakukan segalanya untuk Anda. Semua contoh akan diterjemahkan untuk PostgreSQL, bukan untuk MySQL, sehingga pengguna MySQL harus menciptakan sepeda mereka sendiri.

Tapi bagaimanapun caranya! Dalam praktiknya, ternyata lapisan abstraksi ini benar-benar penuh lubang, seperti palung dari kisah Ikan Emas. Dan banyak fitur dasar tidak dapat digunakan, seperti membandingkan tanggal atau bekerja dengan array. Dan Anda mendapatkan cakupan dengan paksa di where("#{quoted_table_name}.finished_at >= ?", Date.current)
atau di where("#{quoted_table_name}.other_ids <@ ARRAY[?]", ids)
. Ke mana ActiveRecord memberikan jawaban yang sepenuhnya sadar dan logis: jangan gunakan itu. Alih-alih array, gunakan koneksi habtm, dan jika Anda perlu membandingkan tanggal, hiduplah dengan itu. Ya, dan Tuhan melarang Anda untuk melewatkan quoted_table_name
dalam cakupan seperti itu - includes
atau joins
meletakkan semuanya di tempatnya. Lebih mudah di mana-mana dan selalu menulis, agar tidak menjatuhkan tangan Anda.
Yah, tentu saja, begitu Anda memutuskan untuk mengganggu ActiveRecord di sini, tidak akan ada jalan untuk kembali. Bukan berarti tidak akan ada kesempatan, bahkan harapan hantu untuk transisi tanpa rasa sakit ke database lain. Akan lebih mudah untuk mencetak dan membakar kode sumber ini. Dan tentu saja, tidak ada alasan lain untuk tidak menggunakan fitur basis data tambahan dalam aplikasi Anda. Gunakan untuk kesehatan dan paksa orang lain!
Dan ketika ternyata lingkup Anda dalam folder model terdiri dari lebih dari setengah fitur tambahan ini, cukup jelas bahwa ActiveRecord hanyalah sebuah shell yang nyaman untuk mengintegrasikan satu bagian kode dengan label dengan potongan kode lainnya. Dan cakupan, seperti di where(is_archived: true).joins(:sprint).merge(Sprint.archived)
, akan bekerja dengan baik dan menggabungkannya tidak akan lebih sulit daripada membuat telur orak, kan?

Tahap selanjutnya adalah denormalisasi. Tidak, denasionalisasi, seolah-olah, selalu tidak lenyap, tetapi perawatannya berada di pundak Rails dan ActiveRecord yang kuat, dan Anda tahu bahwa kedua orang ini tidak berbeda dalam kecepatan dan asketisme dalam persyaratan sumber daya. Katakanlah counter_cache: true
adalah langkah pertama menuju denormalisasi, karena COUNT(*) AS sprints_count
tidak akan memungkinkan Anda membuat ActiveRecord (Anda tidak ingin mengubah select()
, kan?). Dan counter_cache
tidak jauh dari sempurna, dan dalam beberapa kasus mungkin ada sinkronisasi dari jumlah riil dari yang di-cache. Tentu saja tidak kritis, tetapi tidak menyenangkan. Dan ini hanya kandidat pertama yang menetap di database dan tidak memuat kepala mesin ruby โโyang sudah dimuat. Hanya beberapa pemicu dan Anda selesai! Pertama, ketika menghapus dan menambahkan catatan baru ke piring A, Anda perlu menghitung jumlah catatan di pelat B dan hanya itu, kan? Nah, saat mengedit, tentu saja, jika foreign_key
berubah, karena UPDATE B SET a_id = $1 WHERE id = $2
akan memecah counter_cache untuk A lama dan baru.
CREATE OR REPLACE FUNCTION update_#{parent_table}_#{child_table}_counter_on_insert() RETURNS TRIGGER AS $$ BEGIN UPDATE #{parent_table} SET #{counter_column} = COALESCE((SELECT COUNT(id) FROM #{child_table} GROUP BY #{foreign_column} HAVING #{foreign_column} = NEW.#{foreign_column}), 0) WHERE (#{parent_table}.id = NEW.#{foreign_column}); RETURN NULL; END; $$ LANGUAGE plpgsql;
Jalur basis data berikutnya akan terkait dengan tanggal-waktu. Dan sebagai permulaan, mari kita melayani bidang created_at
dan updated_at
dalam database, untungnya, ini jauh lebih sederhana. Pengaturan default pertama:
change_column_default :table_name, :created_at, -> { 'CURRENT_TIMESTAMP' } change_column_default :table_name, :updated_at, -> { 'CURRENT_TIMESTAMP' }
Dan untuk segera melakukannya di mana saja, Anda dapat mengatur siklus untuk semua pelat tempat bidang ini berada. Selain ar_internal_metadata
dan ar_internal_metadata
, tentu saja:
(tables -
Itu saja, sekarang nilai default untuk tabel ini akan persis seperti yang kita butuhkan. Dan sekarang adalah waktu untuk memastikan bahwa pagar tidak menyentuh bidang ini. Ini dilakukan dengan dua baut di tempat yang tepat. Dan ya, ada opsi dalam menyiapkan kerangka kerja yang bertanggung jawab untuk ini:
Rails.application.config.active_record.record_timestamps = false
Jadi, langkah selanjutnya adalah memperbarui bidang updated_at
pada saat catatan diperbarui. Sederhana:
CREATE OR REPLACE FUNCTION touch_for_#{table_name}_on_update() RETURNS TRIGGER AS $$ BEGIN SELECT CURRENT_TIMESTAMP INTO NEW.updated_at; RETURN NEW; END; $$ LANGUAGE plpgsql;
Sekarang Anda harus benar-benar menghilangkan touch: true
dalam model. Hal ini sangat mirip dengan target di dasbor - juga sepenuhnya berlubang. Dan saya bahkan tidak akan menjelaskan mengapa, karena Anda sudah tahu semua kasus ini. Ini tidak jauh lebih rumit, yang Anda butuhkan adalah memperbarui updated_at tidak hanya untuk diri Anda sendiri:
CREATE OR REPLACE FUNCTION touch_for_#{table_name}_on_update() RETURNS TRIGGER AS $$ BEGIN UPDATE foreign_table_name SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.foreign_column_name; SELECT CURRENT_TIMESTAMP INTO NEW.updated_at; RETURN NEW; END; $$ LANGUAGE plpgsql;
Tentu saja, rantai panggilan pemicu tersebut akan melakukan tindakan yang tidak perlu, tetapi dalam postgres mekanisme waras, pemicu dipanggil tanpa mengubah catatan itu sendiri. Anda dapat mencoba melakukan SET title = title
, tetapi hasilnya tidak lebih baik daripada SET updated_at = CURRENT_TIMESTAMP
.
Pemicu yang sama persis akan ada di sisipan, hanya memperbarui updated_at
tidak perlu:
CREATE OR REPLACE FUNCTION touch_for_#{table_name}_on_insert() RETURNS TRIGGER AS $$ BEGIN UPDATE foreign_table_name SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.foreign_column_name; RETURN NEW; END; $$ LANGUAGE plpgsql;
Tentu saja, Anda dapat mencoba menulis ini dengan satu fungsi, menambahkan tanda centang untuk kejadian saat ini tepat di dalam pelatuk, mirip dengan IF TG_OP = 'UPDATE' THEN
, tetapi lebih baik untuk membuat semua pemicu sesederhana mungkin untuk mengurangi kemungkinan kesalahan.
Anda mungkin ingin mengotomatisasi pembuatan pemicu semacam itu, dan kemudian Anda mungkin perlu menemukan semua hubungan asing antara tabel saat ini dan yang lainnya. Anda dapat dengan mudah melakukan ini dengan permintaan ini:
SELECT ccu.table_name AS foreign_table_name, kcu.column_name AS column_name FROM information_schema.table_constraints AS tc JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name JOIN information_schema.constraint_column_usage AS ccu ON ccu.constraint_name = tc.constraint_name WHERE constraint_type = 'FOREIGN KEY' AND tc.table_name = '#{table_name}' ORDER BY ccu.table_name;
Tip lain yang sangat membantu. Pemicu panggilan dengan cara yang sama untuk dapat memverifikasi ada atau tidaknya apa yang dibutuhkan oleh satu permintaan, misalnya, permintaan ini akan menemukan semua pemicu sisipan sentuh:
SELECT routine_name AS name FROM information_schema.routines WHERE routine_name LIKE 'touch_for_%_on_insert' AND routine_type ='FUNCTION' AND specific_schema='public';
Dan terakhir, yang terburuk adalah yang tersisa. Faktanya adalah bahwa rel tidak dirancang untuk setidaknya beberapa jenis database pintar, dan mereka benar-benar tidak peduli dengan fakta bahwa setidaknya sesuatu selain bidang id
dapat berubah dalam database, dan itu hanya ketika dimasukkan. Oleh karena itu, tidak ada mekanisme waras untuk menambahkan RETURNING updated_at
untuk memperbarui permintaan, tidak ada, Anda harus menyelami bagian dalam Rails ke telinga.

Mankipatch ternyata tidak terlalu rapi, tetapi pertama-tama tujuannya adalah untuk merusak pekerjaan kerangka saat ini sesedikit mungkin.
Saya akan membawanya sepenuhnya module ActiveRecord module Persistence # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/persistence.rb#L729-L741 def _create_record(attribute_names = self.attribute_names) attribute_names &= self.class.column_names attributes_values = attributes_with_values_for_create(attribute_names) an_id, *affected_rows = self.class._insert_record(attributes_values).dup self.id ||= an_id if self.class.primary_key Hash[ApplicationRecord.custom_returning_columns(self.class.quoted_table_name, :create).take(affected_rows.size).zip(affected_rows)].each do |column_name, value| public_send("#{column_name}=", self.class.attribute_types[column_name.to_s].deserialize(value)) if value end @new_record = false yield(self) if block_given? id end private :_create_record # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/persistence.rb#L710-L725 def _update_record(attribute_names = self.attribute_names) attribute_names &= self.class.column_names attribute_names = attributes_for_update(attribute_names) if attribute_names.empty? affected_rows = [] @_trigger_update_callback = true else affected_rows = _update_row(attribute_names) @_trigger_update_callback = affected_rows.any? end Hash[ApplicationRecord.custom_returning_columns(self.class.quoted_table_name, :update).take(affected_rows.size).zip(affected_rows)].each do |column_name, value| public_send("#{column_name}=", self.class.attribute_types[column_name.to_s].deserialize(value)) end yield(self) if block_given? affected_rows.none? ? 0 : 1 end private :_update_record end module ConnectionAdapters module PostgreSQL module DatabaseStatements # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb#L93-L96 def exec_update(sql, name = nil, binds = []) execute_and_clear(sql_with_returning(sql), name, binds) { |result| Array.wrap(result.values.first) } end # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L147-L152 def insert(arel, name = nil, pk = nil, _id_value = nil, sequence_name = nil, binds = []) sql, binds = to_sql_and_binds(arel, binds) exec_insert(sql, name, binds, pk, sequence_name).rows.first end alias create insert # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb#L98-L111 def sql_for_insert(sql, pk, id_value, sequence_name, binds) # :nodoc: table_ref = extract_table_ref_from_insert_sql(sql) if pk.nil? # Extract the table from the insert sql. Yuck. pk = primary_key(table_ref) if table_ref end returning_columns = quote_returning_column_names(table_ref, pk, :create) if returning_columns.any? sql = "#{sql} RETURNING #{returning_columns.join(', ')}" end super end # No source in original repo def quote_returning_column_names(table_ref, pk, action) returning_columns = [] returning_columns << pk if suppress_composite_primary_key(pk) returning_columns += ApplicationRecord.custom_returning_columns(table_ref, action) returning_columns.map { |column| quote_column_name(column) } end # No source in original repo def sql_with_returning(sql) table_ref = extract_table_ref_from_update_sql(sql) returning_columns = quote_returning_column_names(table_ref, nil, :update) return sql if returning_columns.blank? "#{sql} RETURNING #{returning_columns.join(', ')}" end # No source in original repo def extract_table_ref_from_update_sql(sql) sql[/update\s("[A-Za-z0-9_."\[\]\s]+"|[A-Za-z0-9_."\[\]]+)\s*set/im] Regexp.last_match(1)&.strip end end end end end
Yang paling penting adalah di sini kita beralih ke ApplicationRecord.custom_returning_columns
untuk mencari tahu kolom mana selain id yang menarik bagi kita. Dan metode ini terlihat seperti ini:
class << self def custom_returning_columns(table_ref, action) return [] if ['"schema_migrations"', '"ar_internal_metadata"'].include?(table_ref) res = [] res << :created_at if action == :create res << :updated_at res += case table_ref when '"user_applications"' [:api_token] when '"users"' [:session_salt, :password_changed_at] # ... else [] end res end end
Alih-alih kesimpulan, kita dapat mengatakan bahwa, sakit kepala Rails menjadi sedikit kurang sakit. Proses counter_cache
seperti counter_cache
dan touch
akan terlupakan, dan di artikel berikutnya kita dapat memikirkan sesuatu yang lebih global, seperti menghapus ruang gantung, validasi data, penghapusan data cascading, atau penghapusan paranoid. Jika Anda menyukai artikel ini, tentu saja.