Τεχνικό Άρθρο

Framing των records PivotCache BIFF με HotXLS σε Delphi

Η υποροή PivotCache του BIFF αποθηκεύει το cached σύνολο δεδομένων ενός PivotTable χωριστά από τη θέα που το εμφανίζει, και το HotXLS διαβάζει και γράφει εκείνη την υποροή εξετάζοντας σώματα records αντί να εμπιστεύεται αριθμούς records. Εκείνη η διάκριση είναι όλη η ιστορία: ο ίδιος αριθμός record κουβαλά δύο ασυμβίβαστες διατάξεις σώματος ανάλογα με ποιος writer παρήγαγε το αρχείο, οπότε ο reader αποφασίζει το framing από το πρώτο σώμα record που βλέπει

Συναντάς αυτό το στρώμα τη στιγμή που ένα PivotTable πρέπει να επιβιώσει από round trip. Μια pivot θέα χωρίς το cache της είναι κέλυφος, και το Excel θα ξαναχτίσει το cache από το source range όταν ανοίγει το αρχείο, κάτι απολύτως καλό μέχρι το σημείο όπου το source range έχει χαθεί, τα δεδομένα επικολλήθηκαν από query, ή το βιβλίο είναι αρχειοθετημένο close που δεν πρέπει να αλλάζει όταν κάποιος το ανοίγει

Δύο δομές, δύο σημεία στο αρχείο

Τα cached δεδομένα και ο ορισμός του cache ζουν σε διαφορετικά μέρη του βιβλίου εργασίας, και η σύγχυση των δύο είναι το πρώτο που πρέπει να κάνεις σωστά. Τα cached records σχηματίζουν δική τους υποροή, δεδομένη στο [MS-XLS] §2.1.7.12 ως PIVOTCACHE = SXDB SXDBEx *SXFORMULA *FDB *DBB EOF. Προσέξτε τι λείπει: δεν υπάρχει BOF στην αρχή εκείνης της παραγωγής

Ο ορισμός κάθεται στα workbook globals αντίθετα, ως PIVOTCACHEDEFINITION = SXStreamID SXVS [SXSRC] [SXADDLCACHE] (§2.1.7.20.3), τοποθετημένος μετά τα records μορφοποίησης και πριν τα records BoundSheet και Country. Έτσι ένα μεμονωμένο cache περιγράφεται σε δύο σημεία που απέχουν εκατοντάδες records, και ο σύνδεσμος μεταξύ τους είναι αναγνωριστικό stream που πρέπει να συμφωνεί σε τρεις τοποθεσίες ταυτόχρονα

Framing PivotCache του HotXLS σε BIFF8: το PIVOTCACHEDEFINITION με το SXStreamID του κάθεται στα workbook globals μετά τη μορφοποίηση και πριν το BoundSheet, ενώ τα cached records ζουν σε stream κάτω από την αποθήκευση _SX_DB_CUR ονομασμένη με κεφαλαίο hex τεσσάρων ψηφίων που κρατά records SXDB, SXDBEx, SXFORMULA, FDB και DBB χωρίς BOF, και SXStreamID.idStm, το πεδίο idstm του SXDB και το όνομα του stream πρέπει να συμφωνούν
Ένα μεμονωμένο pivot cache περιγράφεται σε δύο σημεία που απέχουν εκατοντάδες records, ενωμένα με αναγνωριστικό stream που πρέπει να συμφωνεί στα globals, στην κεφαλίδα SXDB και στο όνομα της υποροής ταυτόχρονα

Κάθε cache ανήκει σε stream κάτω από _SX_DB_CUR του οποίου το όνομα είναι η κεφαλαία δεκαεξαδική απόδοση τεσσάρων ψηφίων του αναγνωριστικού του. Το SXStreamID.idStm, το πεδίο idstm επαναλαμβανόμενο στην κεφαλίδα SXDB, και εκείνο το όνομα stream πρέπει όλα να ταιριάζουν. Όταν δεσμεύετε νέο αναγνωριστικό, κρατήστε πρώτα κάθε αριθμό ήδη διαβασμένο από το αρχείο, αλλιώς ένα νέο cache μπορεί να διεκδικήσει αριθμό που ανήκει σε παλαιότερο cache που ο reader δεν έχει φτάσει ακόμα

Ένα ακόμη αναγνωριστικό πιάνει κόσμο. Η τιμή iCache σε pivot θέα είναι η θέση με βάση το μηδέν του αντίστοιχου SXStreamID στην καθολική ακολουθία, και όχι αναγνωριστικό cache που διαλέγετε εσείς. Στην εγγραφή πρέπει να αντιστοιχίζεται από το αντικείμενο cache στην πραγματική θέση εξόδου του, και υπάρχουσες θέες πρέπει να αναριθμούνται μαζί της, αλλιώς η αναβάθμιση ενός cache δείχνει σιωπηλά μια θέα σε διαφορετικό

var
  Book: TXLSWorkbook;
  Cache: TXLSPivotCache;
  Field: TXLSPivotCacheField;
  V: TXLSPivotCacheValue;
begin
  Book := TXLSWorkbook.Create(nil);
  try
    Book.LoadFromFile('sales.xls');
    Cache := Book.PivotCaches.Add;
    Cache.SourceRangeSheet := 'Data';
    Cache.SourceFirstRow := 1;  Cache.SourceFirstCol := 1;
    Cache.SourceLastRow := 500; Cache.SourceLastCol := 6;
    Cache.SourceDataType := 1;        // SXVS SHEET, MS-XLS 2.4.317
    Cache.RefreshOnLoad := False;     // εμπιστεύσου τα cached records
    Cache.SaveData := True;

    Field := Cache.AddField('Region', xlpcftString);
    V.ValueType := xlpcftString;
    V.StrValue := 'North';
    Field.FindOrAddItem(V);

    Cache.SetRecordCount(0);          // καθάρισμα, μετά διάσταση του πλέγματος records
    Cache.SetRecordCount(500);
    Book.StorePivotCaches;
  finally
    Book.Free;
  end;
end;

Το διπλό SetRecordCount δεν είναι δεισιδαιμονία. Το RecordCount είναι σκέτη εγγραφή ιδιότητας που δεν δεσμεύει μνήμη, και το εσωτερικό μονοπάτι μεγαλώματος αρχικοποιεί μόνο τις πρόσφατα προστεθείσες γραμμές, οπότε ένα cache του οποίου το πλήθος ορίστηκε μέσω του μονοπατιού κεφαλίδας μπορεί να καταλήξει με πλέγμα δεικτών μηδενικού μήκους. Οι εγγραφές σε RecordIndices απορρίπτονται τότε χωρίς σφάλμα. Ο ορισμός του πλήθους στο μηδέν και πίσω επαναφέρει το πλέγμα, και πρέπει να γίνει αφού προστεθεί κάθε πεδίο, επειδή το πλάτος γραμμής βγαίνει από το πλήθος πεδίων

Γιατί ένας αριθμός record δεν μπορεί να σας πει τη διάταξη σώματος;

Επειδή αριθμοί records και διατάξεις σώματος άλλαξαν σε διαφορετικές χρονικές στιγμές, οπότε η αντιστοίχιση μεταξύ τους δεν είναι συνάρτηση. Ένας αριθμός στο legacy σετ εμφανίζεται μόνο σε αρχεία από παλαιότερους writers, που τον κάνει αξιόπιστο σήμα προς μία κατεύθυνση. Ένας άλλος αριθμός είναι ειλικρινά αμφίσημος: εμφανίζεται και σε σωστά αρχεία και σε μια σειρά ενδιάμεσων εκδόσεων που χρησιμοποιούσαν τον νέο αριθμό με την παλιά διάταξη σώματος

Το framing επομένως πρέπει να αποφασίζεται από το σώμα, και μία φορά ανά υποροή cache και όχι ανά record. Το HotXLS κλειδώνει τη διάλεκτο από το μήκος του πρώτου record SXDBB σε κάθε υποροή. Στο framing της προδιαγραφής, ένα SXDBB κρατά ακριβώς ένα cache record, οπότε το μήκος του ισούται με ένα πλάτος γραμμής. Στο παλαιότερο packed framing, το πρώτο record κρατά όσες γραμμές χωράνε, οπότε για οποιοδήποτε cache με πάνω από μία γραμμή είναι τουλάχιστον δύο πλάτη γραμμής. Η σύγκριση είναι αποφασιστική όποτε οι δύο προβλέψεις διαφέρουν

Latch framing SXDBB του HotXLS: ένας αριθμός record κουβαλά δύο ασυμβίβαστες διατάξεις σώματος, οπότε ο reader συγκρίνει το μήκος του πρώτου record SXDBB με το πλάτος γραμμής, ένα πλάτος γραμμής κλειδώνει τη διάλεκτο της προδιαγραφής ενώ δύο ή περισσότερα πλάτη γραμμής κλειδώνουν το παλαιότερο packed framing, ισοπαλίες παίρνουν την ανάγνωση της προδιαγραφής, και η διάλεκτος κλειδώνει μία φορά ανά υποροή cache, όχι ανά record
Αριθμοί records δεν μπορούν να αποφασίσουν διάταξη σώματος επειδή τα δύο άλλαξαν σε διαφορετικές στιγμές, οπότε το HotXLS κλειδώνει τη διάλεκτο μία φορά ανά υποροή από το πρώτο μήκος SXDBB και παίρνει την ανάγνωση της προδιαγραφής σε ισοπαλία

Όταν δεν διαφέρουν, ο reader παίρνει την ανάγνωση της προδιαγραφής, με την αρχή ότι τα αρχεία γραμμένα από Excel υπερτερούν αριθμητικά των αρχείων γραμμένων από ενδιάμεσο build. Εκείνο το τυφλό σημείο είναι στενό εκ κατασκευής και, όταν όντως συμβεί, το αρχείο ίδιο εξακολουθεί να επαναλαμβάνεται byte προς byte. Μόνο οι τυπολογημένοι δείκτες εκτεθειμένοι σε callers επηρεάζονται

Το πλάτος δείκτη ζει σε διαφορετικό record

Το SXDBB (§2.4.276) κουβαλά έναν δείκτη ανά πεδίο cache του οποίου η σημαία distinct-value είναι ορισμένη, σε σειρά πεδίων, και το πλάτος κάθε δείκτη αποφασίζεται αλλού: το αντίστοιχο record πεδίου SXFDB (§2.4.283) δηλώνει σημαία short-items, και εκείνη η σημαία λέει αν ο δείκτης καταλαμβάνει δύο bytes ή ένα. Δύο records, μία υπονοούμενη σύμβαση, και μία μόνο πρόταση στην προδιαγραφή που τα συνδέει

Εκείνη η σύζευξη είναι ακριβώς εκεί όπου μια αυτοσχεδιασμένη κωδικοποίηση πηγαίνει στραβά. Ένας παλαιότερος writer του HotXLS πακέταρε κάθε πεδίο στον ελάχιστο αριθμό bits, με γέμισμα προς όριο byte ανάμεσα στις γραμμές, που μπορεί κανείς να υπερασπιστεί μεμονωμένα και αντιφάσκει ευθέως με το πλάτος που ο ίδιος writer μόλις είχε δηλώσει στο SXFDB. Ένα πεδίο με τρεις διακριτές τιμές περιγραφόταν ως πλάτος ενός byte στο ένα record και καταλάμβανε δύο bits στο άλλο. Η λύση δεν ήταν να διορθωθεί η αριθμητική αλλά να εξαχθεί η απόφαση πλάτους σε μία συνάρτηση που καλούν και οι δύο emitters, ώστε τα δύο records να μην μπορούν πια να αποκλίνουν. Αυτό είναι η ίδια κατηγορία ελαττώματος που περιγράφεται στο BIFF record length declaration drift, όπου δηλωμένο μέγεθος και πραγματικό σώμα χωρίζουν δρόμο

Η συνέπεια του να μην διαβάζεις καθόλου αυτά τα records αξίζει να ειπωθεί καθαρά, γιατί είναι εύκολο να υποτιμηθεί. Όταν ο reader προσπερνούσε τους δείκτες records, κάθε cache φορτωμένο από αρχείο ανέφερε δείκτη μηδέν για κάθε πεδίο κάθε γραμμής, που σημαίνει ότι κάθε γραμμή έδειχνε την πρώτη τιμή κάθε πεδίου. Δεν είναι απλώς μειωμένη ενδοσκόπηση: το μονοπάτι αξιολόγησης pivot και το μονοπάτι γεμίσματος cache προς κελί και τα δύο καταναλώνουν εκείνο το πλέγμα. Και ένα test round-trip δεν το ανιχνεύει, επειδή ένα cache ακόμα σε raw replay ξαναγράφεται από τα πρωτότυπά του bytes

// Οι σημαίες provenance σας λένε τι κρατάτε και τι μπορεί να ξαναγραφτεί
if Cache.FromRawBlobs then
begin
  Writeln('stream id        : ', IntToHex(Cache.StreamId, 4));
  Writeln('legacy framing   : ', Cache.RawFramingIsLegacy);
  Writeln('own storage      : ', Cache.RawHasStorageStream);
  Writeln('model complete   : ', Cache.RawModelIsComplete);
  // Η επανέκδοση είναι lossless μόνο όταν κάθε record έχει μοντέλο εδώ
  if Cache.CanUpgradeFraming then
    Writeln('safe to rewrite with the current emitters');
end;

Πότε η επανεγγραφή ενός cache είναι lossless;

Μόνο όταν τρεις συνθήκες ισχύουν μαζί, και η CanUpgradeFraming είναι η μοναδική ιδιότητα που απαντά την ερώτηση. Το cache πρέπει ακόμα να βρίσκεται σε raw replay, η υποροή πρέπει να είναι σε ένα από τα framings που αυτή η βιβλιοθήκη έγραφε λανθασμένα στο παρελθόν, και ο reader πρέπει να έχει χτίσει πλήρες τυπολογημένο μοντέλο κάθε record μέσα της. Ένα cache γραμμένο από Excel δεν πληροί ποτέ, γιατί η υποροή του κουβαλά records για τα οποία το HotXLS δεν έχει μοντέλο, και η επανέκδοση από το μοντέλο θα τα έχανε

Το test πληρότητας είναι αυστηρότερο απ όσο φαίνεται πρώτα. Ένα record που ο reader κράτησε μόνο ως αδιαφανή bytes σημαίνει το μοντέλο ατελές. Το ίδιο ισχύει και για δηλωμένο πλήθος formula records που ο emitter δεν μπορεί να αναπαραγάγει, επειδή η επανέκδοση θα ξαναγράψει δήλωση πολλών formula records ως δήλωση κανενός, και μια τιμή στο αρχείο που δεν μπορεί να αναπαραγάγει ισοδυναμεί με record που δεν μπορεί να αναπαραγάγει

Συνειδητός συντηρητισμός διαπερνά και τον writer. Οι δείκτες σφίγγονται στο νόμιμο εύρος αντί να κωδικοποιούνται ως out-of-band sentinel, επειδή η προδιαγραφή ορίζει δείκτη στην ακολουθία διακριτών τιμών και τίποτα άλλο, και ένα κενό κελί είναι από μόνο του τιμή σε εκείνη την ακολουθία. Σώμα cache record που υπερβαίνει το ταβάνι record BIFF δεν γράφεται καθόλου, που θα απαιτούσε χιλιάδες πεδία cache και είναι απρόσιτο μέσα στο όριο στηλών BIFF8 πάντως· η εφεδρεία είναι ότι το Excel κάνει refresh από το source range, που είναι ορισμένη συμπεριφορά και όχι χαλασμένο αρχείο

Οι ημερομηνίες κουβαλάνε την τελευταία εξάρτηση δια-records. Η μετατροπή serial σε ημερομηνία εξαρτάται από το σύστημα ημερομηνιών του βιβλίου εργασίας, και ο emitter records δεν μπορεί να δει το βιβλίο, οπότε η επιλογή βάσης ημερομηνίας περνά ως παράμετρος που έχει προεπιλογή το σύστημα 1900 και τροφοδοτείται από το μονοπάτι αποθήκευσης επιπέδου βιβλίου. Κάτω από το σύστημα 1900 ο αριθμός serial είναι η τιμή ευθέως· το σύστημα 1904 διαφέρει κατά 1462 ημέρες. Η φαρδύτερη μεταχείριση των date serials είναι στο date serials, το σύστημα 1904 και μορφές αριθμών

Αν δουλεύετε στο στρώμα θέας και όχι στο στρώμα cache, τα records που περιγράφουν το ορατό pivot καλύπτονται στο σετ records PivotTable BIFF8, και η συμπεριφορά πλευράς υπολογισμού στα υπολογιζόμενα πεδία, υπολογιζόμενα στοιχεία και refresh. Και τα τρία στρώματα παραδίδονται στο HotXLS Delphi spreadsheet component, που είναι όσο κάνει δυνατή τη φόρτωση legacy βιβλίου εργασίας, την εξέταση του τι περιέχει πραγματικά το cache του, και την απόφαση αν η επανεγγραφή του είναι ασφαλής προτού την κάνετε