DynamoDB için PartiQL ayrıştırıcısını neden elle yazdık
DynamoDB, 'in dar bir dilimini kabul eder ve geri kalan
her şeyi istek anında reddeder. GROUP BY? ValidationException. İfade
düzeyinde bir LIMIT? ValidationException. * operatörü, CAST, bir alt
sorgu? Hepsi kafanızda sorunsuz ayrıştırılır, ağ üzerinden gider ve
sunucuda ölür. Bu bilginin yaşadığı tek yer AWS dokümantasyonu ve hata
mesajlarıydı; bu da DynamoDB için her editörün — bir süre boyunca bizimki
de dahil — motorun kesinlikle reddedeceği bir ifadeyi yazmanıza seve seve
izin vermesi anlamına geliyordu.
Reddin editörde, tuş vuruşunda, tam ilgili cümlenin altında kırmızı bir dalgalı çizgiyle ve bir yeniden yazım mevcut olduğunda tek tıklamalık bir düzeltmeyle gerçekleşmesini istedik. Bu editör ihtiyacı, DynamoDB'nin PartiQL lehçesi için elle yazılmış bir lexer'a ve CST ayrıştırıcısına dönüştü ve bu hafta onu açık kaynak yaptık: dynamodb-partiql-parser, saf TypeScript, sıfır bağımlılık, MIT; CodeMirror bağlantıları ise ayrıca codemirror-lang-partiql olarak yayımlandı. Bu yazı, neden elle yazıldığı, ilk linter'ın neyi yanlış yaptığı ve yalnızca birisi çöp yapıştırdığında ortaya çıkan iki hata hakkındadır.
Regex iyiydi, ta ki olmayana kadar
DynoTable'daki ilk PartiQL linter'ı yaklaşık 650 satırlık regex ve token
taramasıydı ve gerçekten kullanışlıydı: on dokuz ayrı kontrol, yaygın
tuzaklar için hızlı düzeltmeler (IN (...)'dan [...]'a, LIKE'dan
contains()'a, IS NULL'dan attribute_not_exists()'a). Yayımlandı,
gerçek hataları yakaladı, kullanıcılar kapsadığı durumlar için "sorgum
neden başarısız oluyor" biletleri açmayı bıraktı.
Ama bir regex linter'ı desenleri bilir, yapıyı değil.
SELECT price * quantity içindeki *'ın DynamoDB'nin reddettiği bir
aritmetik olduğunu göremiyordu, çünkü * aynı zamanda "tüm sütunlar"
anlamına gelir ve bu ikisini birbirinden ayırmak gerçekten ayrıştırmayı
gerektirir. Tanı
aralıkları yaklaşık değerlerdi — bir satırı işaret edecek kadar yakın,
metni tam ofsetlerde birleştiren bir hızlı düzeltmeyi sürmek için fazla
kaba. Ve her yeni kontrol yığını daha da kırılganlaştırıyordu, çünkü her
regex diğer her regex'in varsayımlarına karşı kendini savunmak zorundaydı.
"Linter'ın yapıya ihtiyacı var"ın çözümü bir ayrıştırıcıdır. Soru, hangisi olduğuydu.
Kimse bir tane yazmamıştı
Workbench'in gerçek SQL tarafında bunu
zaten yaşamıştık: bize yalan söyleyen
hazır bir SQL ayrıştırıcısı, yerini her düğümde bir kaynak aralığı taşıyan
ve tırnaklı-tırnaksız tanımlayıcıları koruyan sql-parser-cst'ye bıraktı.
O deneyim, PartiQL tarafının neye ihtiyaç duyduğunun çıtasını belirledi —
kayıplı bir AST değil, kayıpsız bir somut sözdizim ağacı.
Ama PartiQL, bir ayrıştırıcı açısından önemli olan yerlerde SQL değildir.
DynamoDB'nin lehçesi IN listelerini köşeli parantezle yazar
(WHERE OrderID IN [100, 300, 234]), bag literalleri (<<'a', 'b'>>),
tırnaklı anahtarlı map literalleri ({'rating': 5}), bir MISSING
literali, liste indeksli doküman yolları
(Devices.FireStick.DateWatched[0]) ve RETURNING ALL OLD * içerir —
bunların hiçbirini bir SQL dilbilgisi bilmez. Diğer yönde ise, bir SQL
dilbilgisinin ısrar ettiği şeylerin yarısından yoksundur. O sırada npm'deki
ayrıştırıcılar, genel PartiQL için AWS'nin Rust uygulamasının WebAssembly
derlemeleriydi ve DynamoDB'nin özel olarak neyi reddettiğine dair hiçbir
fikirleri yoktu.
Bu yüzden bir tane yazdık: küçük bir lexer ve sql-parser-cst'nin bize
istemeyi öğrettiği biçime göre modellenmiş, özyinelemeli inişli
(recursive-descent) bir ayrıştırıcı. Her düğüm kendi bayt aralığını taşır.
Bütünün sıfır çalışma zamanı bağımlılığı var — CI'ın artık doğruladığı bir
özellik, çünkü ayrıştırıcıyı tarayıcı dahil, sizin projeniz dahil her yere
gömülebilir kılan şey bu.
Dilbilgisi kolay yarıydı. Bir linter'ın ayrıştırıcısı tüm hayatını bozuk kod ayrıştırarak geçirir. Tuş vuruşunun ortası, yarım bir ifade, üçüncü cümlede bir yazım hatası. İlk hatada durmak editörü işe yaramaz hale getirirdi, bu yüzden ayrıştırıcı hataya toleranslıdır: bir tanı kaydeder, yeniden senkronize olur ve devam eder; böylece ikinci cümle eksikken dördüncü cümle yine de lint'lenir.
Uçağı düşürmeden motoru değiştirmek
Ayrıştırıcı hazır olduğunda, regex linter'ının dört fonksiyonu editörün her yerinde yük taşıyordu — bir ifadenin otomatik çalıştırılmasının güvenli olup olmadığına karar veren de dahil. Bu davranışı sessizce değiştirmek "editör sorgumu çalıştırmıyor" olarak ortaya çıkar; bu, kullanıcıların bildirmekten çok yüzünden çekip gittiği türden bir hatadır.
Bu yüzden değişim bir strangler oldu: eski linter yeniden adlandırıldı, donduruldu ve ağaçta tutuldu. Yeni, ayrıştırıcı tabanlı linter tam olarak aynı dört fonksiyonu yeniden dışa aktardı. Ve bir eşitlik (parity) korpusu her fixture'ı her iki linter'dan geçirip çıktıları birbirine sabitledi — regex sürümünün ürettiği her tanıyı, ayrıştırıcı sürümü de üretmek zorundaydı; fazlasını üretmesine ancak ondan sonra izin verildi. Eski linter bugün hâlâ orada, donmuş halde, değişimin neyi vaat ettiğinin çalıştırılabilir dokümantasyonu olarak.
Yalnızca çöpün bulduğu hatalar
İki arıza hiçbir gerçek sorguda ortaya çıkmadı ve her ikisi de editörü çökertecekti.
Bir CodeMirror linter'ı, dokümanın üzerinde, her değişiklikte, üstünde
hiçbir hata toplayıcısı olmadan senkron çalışır. Yakalanmayan tek bir
istisna bir lint'i başarısız kılmaz — editörü beyaz ekrana düşürür. Ve
özyinelemeli inişli bir ayrıştırıcının içinde doğal bir yakalanmamış istisna
gömülüdür: çağrı yığını. Birkaç bin parantez derinliğinde [[[[[[… ya da
bir NOT NOT NOT … zinciri yapıştırın; her iç içe geçme düzeyi bir yığın
çerçevesidir ve V8 sonunda RangeError: Maximum call stack size exceeded
hatasını doğrudan linter'ın içinden fırlatır.
Düzeltmeler kasıtlı olarak sıkıcı. İfade özyinelemesinin katı bir derinlik
tavanı var — beş yüz düzey, bir insanın yazacağı her şeyin çok ötesinde,
yığın bütçesinin epey altında — bunun ötesinde ayrıştırıcı istisna fırlatmak
yerine tek bir tanı yayar. Ve yapıştırmaların gerçekçi biçimde zincirlendiği
yapılar — binlerce kol uzunluğunda A UNION B UNION C … gibi —
özyinelemeden düz listelere dönüştürüldü: kol başına bir çerçeve yerine tek
bir parseSelect çerçevesi ve bir küme işlemleri dizisi. Stres paketi artık
her derlemede 100 KB çöp ve 30.000 derinliğinde operatör zincirleri
yapıştırıyor ve genel paket tüm boru hattını asla istisna fırlatmayan bir
lint() giriş noktasına sarıyor; çünkü bunu gömecek bir sonraki editör de
bizimkiyle aynı hata-toplayıcısı-yok sorununu yaşayacak.
AWS'nin dokümanlarına karşı denetleyebileceğiniz bir test paketi
Lehçe kuralları — DynamoDB'nin neyi kabul ettiği, neyi reddettiği, hangi yeniden yazımın neyi düzelttiği — hepsi AWS'nin PartiQL referansından geliyor. Dokümantasyondan türetilmiş davranışın kendine özgü bir arıza biçimi vardır: doküman değişir, kod değişmez ve kimse fark etmez.
Bu yüzden korpus ona göre yapılandırıldı. İki yüz sekiz fixture ve her biri, kuralın geldiği AWS dokümantasyon sayfasının URL'siyle başlıyor. Bir kapsam tablosu, belgelenmiş her kuralı kendi fixture'ına eşler ve bir kural fixture'ını kaybederse paket başarısız olur. AWS lehçeyi değiştirdiğinde, fark, üzerinde bir kaynak göstermesi bulunan bir fixture farkıdır.
Bu disiplin, açık kaynak yaptığımız hafta kendini amorti etti. Linter'ın
IN listesi uyarısı iki üst sınır gösteriyordu: partition anahtarı
sütununda 50 değer, anahtar olmayan sütunda 100. Yayından önce her sayıyı
yeniden doğrularken, 100'ü AWS'nin güncel dokümantasyonunda teyit edebildik
— ama 50'yi yürürlükteki hiçbir dokümanda bulamadık. Blog yazılarının ve
eski forum cevaplarının her yerinde yaşamayı sürdürüyor, ama birincil kaynak
yoluna devam etmiş. Linter tesadüfen doğruyu yapıyordu (yalnızca 100'ün
ötesinde uyarıyor, çünkü şemanız olmadan hangi durumun geçerli olduğunu
ayırt edemez) ve yorum satırı artık iddianın hangi yarısının belgelenmiş,
hangisinin halk hikâyesi olduğunu tam olarak söylüyor.
Bir tane kuruyorsanız ne aktarılır
- Küçük bir lehçe için elle yazılmış, özyinelemeli inişli bir ayrıştırıcı ayların değil, günlerin işidir ve her hata mesajı sizindir. "Bir ayrıştırıcı yaz"ın korkutucu sürümü büyük bir dilbilgisi varsayar.
- AST değil, CST kurun. Tanıları hızlı düzeltmelere çeviren şey, her düğümdeki bayt aralıklarıdır; kayıplı bir ağaç metni birleştiremez.
- Ayrıştırıcı bir linter'ı besliyorsa, asıl özellik hata toleransıdır. Toparlanın ve devam edin; ilk hatada duran bir ayrıştırıcı, ondan sonrasında hiçbir şeyi lint'lemez.
- Motorları, eskiyi yeniye sabitleyen bir eşitlik korpusuyla, donmuş bir arayüzün arkasında değiştirin. Eski uygulama, üzerinde zaten anlaştığınız spesifikasyondur.
- Girdinin iç içe geçebildiği her yerde, birileri saçma derecede iç içe geçen bir şey yapıştıracaktır. Özyinelemeye derinlik sınırı koyun ve zincirleri düzleştirin; yalnızca sorgularla değil, çöple test edin.
- Testlerinizde kaynaklarınızı gösterin. Kodladığı doküman sayfasını adlandıran bir fixture, doküman değiştiğinde denetlenebilen bir testtir — ve değişecektir.
Ayrıştırıcı GitHub'da
ve npm'de (npm install dynamodb-partiql-parser), editör entegrasyonu ise
codemirror-lang-partiql
içinde. Ayrıştırıcı yerine lehçenin kendisini istiyorsanız,
PartiQL ve SQL DynamoDB'nin alt kümesinin
neler yapıp yapamayacağını kapsar ve
PartiQL örnekleri pratik gezintidir;
tüm bunların uğruna kurulduğu editör
DynoTable içindedir ve onu
ücretsiz deneyebilirsiniz.