.NET Data Community Standup - Azure SQL Database and SQL Server 2022: what’s new for developers
During this show we will discuss some of the new capabilities introduced on both SQL Server and our Azure services that have an impact on app development like:
T-SQL and IQP enhancements
Local Development Experience
JSON enhancements
DataAPI Builder
And more…
ایندکسهای XML ایی
ایندکسهای XML ایی، ایندکسهای خاصی هستند که بر روی ستونهایی از نوع XML تعریف میشوند. هدف از تعریف آنها، بهینه سازی اعمال مبتنی بر XQuery، بر روی دادههای این نوع ستونها است. چهار نوع XML Index قابل تعریف هستند؛ اما primary xml index باید ابتدا ایجاد شود. در این حالت جدولی که دارای ستون XML ایی است نیز باید دارای یک clustered index باشد. هدف از primary XML indexها، ارائهی تخمینهای بهتری است به بهینه ساز کوئریها در SQL Server.
جزئیات primary XML indexها
زمانیکه یک primary xml index را ایجاد میکنیم، node table یاد شده در قسمت قبل را، بر روی سخت دیسک ذخیره خواهیم کرد (بجای هربار محاسبه در زمان اجرا). متادیتای این اطلاعات ذخیره شده را در جداول سیستمی sys.indexes و sys.columns میتوان مشاهده کرد. باید دقت داشت که تهیهی این ایندکسها، فضای قابل توجهی را از سخت دیسک به خود اختصاص خواهند داد؛ چیزی حدود 2 تا 5 برابر حجم اطلاعات اولیه. بدیهی است تهیهی این ایندکسها که نتیجهی تجزیهی اطلاعات XML ایی است، بر روی سرعت insert تاثیر خواهند گذاشت. Node table دارای ستونهایی مانند نام تگ، آدرس تگ، نوع داده آن، مسیر و امثال آن است.
زمانیکه یک Primary XML Index تعریف میشود، اگر به Query Plan حاصل دقت کنید، دیگر خبری از XML Readerها مانند قبل نخواهد بود. در اینجا Clustered index seek قابل مشاهدهاست.
ایجاد primary XML indexها
همان مثال قسمت قبل را که دو جدول از آن به نامهای xmlInvoice و xmlInvoice2 ایجاد کردیم، درنظر بگیرید. اینبار یک xmlInvoice3 را با همان ساختار و همان 6 رکوردی که معرفی شدند، ایجاد میکنیم. بنابراین برای آزمایش جاری، در مثال قبل، هرجایی xmlInvoice مشاهده میکنید، آنرا به xmlInvoice3 تغییر داده و مجددا جدول مربوطه و دادههای آنرا ایجاد کنید.
اکنون برای ایجاد primary XML index بر روی ستون invoice آن میتوان نوشت:
CREATE PRIMARY XML INDEX invoice_idx ON xmlInvoice3(invoice) SELECT * FROM sys.internal_tables
در ادامه علاقمند هستیم که بدانیم داخل آن چه چیزی ذخیره شدهاست:
SELECT * FROM sys.xml_index_nodes_325576198_256000
اگر به این جدول دقت کنید، 6 ردیف اطلاعات XML ایی، به حدود 100 ردیف اطلاعات ایندکس شده، تبدیل گردیدهاست. با استفاده از دستور ذیل میتوان حجم ایندکس تهیه شده را نیز مشاهده کرد:
sp_spaceused 'xmlInvoice3'
--DROP INDEX invoice_idx ON xmlInvoice3
تاثیر primary XML indexها بر روی سرعت اجرای کوئریها
همان 10 کوئری قسمت قبل را درنظر بگیرید. اینبار برای مقایسه میتوان به نحو ذیل عمل کرد:
SELECT * FROM xmlInvoice WHERE invoice.exist('/Invoice[@InvoiceId = "1003"]') = 1 SELECT * FROM xmlInvoice3 WHERE invoice.exist('/Invoice[@InvoiceId = "1003"]') = 1
چند نکته در این تصویر حائز اهمیت است:
- Query plan کوئری انجام شده بر روی جدول دارای primary XML index، مانند قسمت قبل، حاوی XML Readerها نیست.
- هزینهی انجام کوئری بر روی جدول دارای XML ایندکس نسبت به حالت بدون ایندکس، تقریبا نزدیک به صفر است. (بهبود کارآیی فوق العاده)
اگر کوئریهای دیگر را نیز با هم مقایسه کنید، تقریبا به نتیجهی کمتر از یک سوم تا یک چهارم حالت بدون ایندکس خواهید رسید.
همچنین اگر برای حالت دارای Schema collection نیز ایندکس ایجاد کنید، اینبار کوئری پلن آن اندکی (چند درصد) بهبود خواهد یافت ولی نه آنچنان.
ایندکسهای XMLایی ثانویه یا secondary XML indexes
سه نوع ایندکس XML ایی ثانویه نیز قابل تعریف هستند:
- VALUE : کار آن بهینه سازی کوئریهای content و wildcard است.
- PATH : بهینه سازی انتخابهای مبتنی بر XPath را انجام میدهد.
- Property: برای بهینه سازی انتخاب خواص و ویژگیها بکار میرود.
این ایندکسها یک سری non-clustered indexes بر روی node tables هستند. برای ایجاد سه نوع ایندکس یاد شده به نحو ذیل میتوان عمل کرد:
CREATE XML INDEX invoice_path_idx ON xmlInvoice3(invoice) USING XML INDEX invoice_idx FOR PATH
پس از ایجاد ایندکس ثانویه بر روی مسیرها، اگر اینبار کوئری دوم را اجرا کنیم، به Query Plan ذیل خواهیم رسید:
همانطور که مشاهده میکنید، نسبت به حالت primary index، وضعیت clustered index seek به index seek تغییر کردهاست و همچنین دقیقا مشخص است که از کدام ایندکس استفاده شدهاست.
در ادامه دو نوع ایندکس دیگر را نیز ایجاد میکنیم:
CREATE XML INDEX invoice_value_idx ON xmlInvoice3(invoice) USING XML INDEX invoice_idx FOR VALUE CREATE XML INDEX invoice_prop_idx ON xmlInvoice3(invoice) USING XML INDEX invoice_idx FOR PROPERTY
سؤال: اکنون پس از تعریف 4 ایندکس یاد شده، کوئری دوم از کدام ایندکس استفاده خواهد کرد؟
در اینجا مجددا کوئری دوم را اجرا کرده و به قسمت Query Plan آن دقت خواهیم کرد:
برای مشاهده دقیق نام ایندکس مورد استفاده، کرسر ماوس را بر روی index seek قرار میدهیم. در اینجا اگر به قسمت object گزارش ارائه شده دقت کنیم، نام invoice_value_idx یا همان value index ایجاد شده، قابل مشاهدهاست؛ به این معنا که در کوئری دوم، اهمیت مقادیر بیشتر است از اهمیت مسیرها.
کوئریهایی مانند کوئری ذیل از property index استفاده میکنند:
SELECT * FROM xmlInvoice3 WHERE invoice.exist('/Invoice//CustomerName[text() = "Vahid"]') = 1
SELECT * FROM xmlInvoice3 WHERE invoice.exist('/Invoice//CustomerName[. = "Vahid"]') = 1
خلاصه نکات بهبود کارآیی برنامههای مبتنی بر فیلدهای XML
- در حین استفاده از XPath، ذکر محور parent یا استفاده از .. (دو دات)، سبب ایجاد مراحل اضافهای در Query Plan میشوند. تا حد امکان از آن اجتناب کنید و یا از روشهایی مانند cross apply و xml.nodes برای مدیریت اینگونه موارد تو در تو استفاده نمائید.
- ordinals را به انتهای Path منتقل کنید (مانند ذکر [1] جهت مشخص سازی نودی خاص).
- از ذکر predicates در وسط یک Path اجتناب کنید.
- اگر اسناد شما fragment با چند root elements نیستند، بهتر است document بودن آنها را در حین ایجاد ستون XML مشخص کنید.
- xml.value را به xml.query ترجیح دهید.
- عملیات casting در XQuery سنگین بوده و استفاده از ایندکسها را غیرممکن میکند. در اینجا استفاده از اسکیما میتواند مفید باشد.
- نوشتن sub queryها بهتر هستند از چندین XQuery در یک عبارت SQL.
- در ترکیب اطلاعات رابطهای و XML، استفاده از متدهای xml.exist و sql:column نسبت به xml.value جهت استخراج و مقایسه اطلاعات، بهتر هستند.
- اگر قصد تهیه خروجی XML از جدولی رابطهای را دارید، روش select for xml کارآیی بهتری را نسبت به روش FLOWR دارد. روش FLOWR برای کار با اسناد XML موجود طراحی و بهینه شدهاست؛ اما روش select for xml در اصل برای کار با اطلاعات رابطهای بهینه سازی گردیدهاست.
SQL Antipattern #2
بخش دوم : Naive Trees
فرض کنید یک وب سایت حرفهای خبری یا علمی-پژوهشی داریم که قابلیت دریافت نظرات کاربران را در مورد هر مطلب مندرج در سایت یا نظرات داده شده در مورد آن مطالب را دارا میباشد. یعنی هر کاربر علاوه بر توانایی اظهار نظر در مورد یک خبر یا مطلب باید بتواند پاسخ نظرات کاربران دیگر را نیز بدهد. اولین راه حلی که برای طراحی این مطلب در پایگاه داده به ذهن ما میرسد، ایجاد یک زنجیره با استفاده از کد sql زیر میباشد:
CREATE TABLE Comments ( comment_idSERIAL PRIMARY KEY, parent_idBIGINT UNSIGNED, comment TEXT NOT NULL, FOREIGN KEY (parent_id) REFERENCES Comments(comment_id) );
البته همان طور که پیداست بازیابی زنجیرهای از پاسخها در یک پرسوجوی sql کار سختی است. این نخها معمولا عمق نامحدودی دارند و برای به دست آوردن تمام نخهای یک زنجیره باید پرسوجوهای زیادی را اجرا نمود.
ایدهی دیگر میتواند بازیابی تمام نظرها و ذخیرهی آنها در حافظهی برنامه به صورت درخت باشد. ولی این روش برای ذخیره هزاران نظری که ممکن است در سایت ثبت شود و علاوه بر آن مقالات جدیدی که اضافه میشوند، تقریبا غیرعملی است.
1.2 هدف: ذخیره و ایجاد پرسوجو در سلسلهمراتب
وجود سلسله مراتب بین دادهها امری عادی محسوب میگردد. در ساختار دادهای درختی هر ورودی یک گره محسوب میگردد. یک گره ممکن است تعدادی فرزند و یک پدر داشته باشد. گره اول پدر ندارد، ریشه و گره فرزند که فرزند ندارد، برگ و گرهای دیگر، گرههای غیربرگ نامیده میشوند.
مثالهایی که از ساختار درختی دادهها وجود دارد شامل موارد زیر است:
Organizational chart: در این ساختار برای مثال در ارتباط بین کارمندان و مدیر، هر کارمند یک مدیر دارد که نشاندهندهی پدر یک کارمند در ساختار درختی است. هر مدیر هم یک کارمند محسوب میگردد.
Threaded discussion: در این ساختار برای مثال در سیستم نظردهی و پاسخها، ممکن است زنجیرهای از نظرات در پاسخ به نظرات دیگر استفاده گردد. در درخت، فرزندان یک گرهی نظر، پاسخهای آن گره هستند.
در این فصل ما از مثال ساختار دوم برای نشان دادن Antipattern و راه حل آن بهره میگیریم.
2.2 Antipattern : همیشه مبتنی بر یکی از والدین
راه حل ابتدایی و ناکارآمد
اضافه نمودن ستون parent_id . این ستون، به ستون نظر در همان جدول ارجاع داده میشود و شما میتوانید برای اجرای این رابطه از قید کلید خارجی استفاده نمایید. پرسوجویی که برای ساخت مثالی که ما در این بحث از آن استفاده میکنیم در ادامه آمده است:
CREATE TABLE Comments ( comment_idSERIAL PRIMARY KEY, parent_idBIGINT UNSIGNED, bug_idBIGINT UNSIGNED NOT NULL, author BIGINT UNSIGNED NOT NULL, comment_dateDATETIME NOT NULL, comment TEXT NOT NULL, FOREIGN KEY (parent_id)REFERENCES Comments(comment_id), FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id), FOREIGN KEY(author) REFERENCES Accounts(account_id) );
لیست مجاورت :
لیست مجاورت خود میتواند به عنوان یک antipattern در نظر گرفته شود. البته این مطلب از آنجایی نشأت میگیرد که این روش توسط بسیاری از توسعهدهندگان مورد استفاده قرار میگیرد ولی نتوانسته است به عنوان راه حل برای معمولترین وظیفهی خود، یعنی ایجاد پرسوجو بر روی کلیه فرزندان، باشد.
• با استفاده از پرسوجوی زیر میتوان فرزند بلافاصلهی یک "نظر" را برگرداند:
SELECT c1.*, c2.* FROM Comments c1 LEFT OUTER JOIN Comments c2 ON c2.parent_id = c1.comment_id;
ضعف پرسوجوی فوق این است که فقط میتواند دو سطح از درخت را برای شما برگرداند. در حالیکه خاصیت درخت این است که شما را قادر میسازد بدون هیچ گونه محدودیتی فرزندان و نوههای متعدد (سطوح بیشمار) برای درخت خود تعریف کنید. بنابراین به ازای هر سطح اضافه باید یک join به پرسجوی خود اضافه نمایید. برای مثال اگر پرسوجوی زیر میتواند درختی با چهار سطح برای شما برگرداند ولی نه بیش از آن:
SELECT c1.*, c2.*, c3.*, c4.* FROM Comments c1 -- 1st level LEFT OUTER JOIN Comments c2 ON c2.parent_id = c1.comment_id -- 2nd level LEFT OUTER JOIN Comments c3 ON c3.parent_id = c2.comment_id -- 3rd level LEFT OUTER JOIN Comments c4 ON c4.parent_id = c3.comment_id; -- 4th level
این پرسوجو به این دلیل که با اضافه شدن ستونهای دیگر، نوهها را سطوح عمیقتری برمیگرداند، پرسوجوی مناسبی نیست. در واقع استفاده از توابع تجمیعی ، مانند COUNT() مشکل میشود.
راه دیگر برای به دست آوردن ساختار یک زیردرخت از لیست مجاورت برای یک برنامه، این است که سطرهای مورد نظر خود را از مجموعه بازیابی نموده و سلسهمراتب مورد نظر را در حافظه بازیابی نماییم و از آن به عنوان درخت استفاده نماییم:
SELECT * FROM Comments WHERE bug_id = 1234;
INSERT INTO Comments (bug_id, parent_id, author, comment) VALUES (1234, 7, 'Kukla' , 'Thanks!' );
UPDATE Comments SET parent_id = 3 WHERE comment_id = 6;
SELECT parent_id FROM Comments WHERE comment_id = 6; -- returns 4 UPDATE Comments SET parent_id = 4 WHERE parent_id = 6; DELETE FROM Comments WHERE comment_id = 6;
- چه تعداد سطح برای پشتیبانی در درخت نیاز خواهیم داشت؟
- من همیشه از کار با کدی که ساختار دادهی درختی را مدیریت میکند، میترسم
- من باید اسکریپتی را به طور دورهای اجرا نمایم تا سطرهای یتیم موجود در درخت را حذف کند.
WITH CommentTree (comment_id, bug_id, parent_id, author, comment, depth) AS ( SELECT *, 0 AS depth FROM Comments WHERE parent_id IS NULL UNION ALL SELECT c.*, ct.depth+1 AS depth FROM CommentTreect JOIN Comments c ON (ct.comment_id = c.parent_id) ) SELECT * FROM CommentTree WHERE bug_id = 1234;
SELECT * FROM Comments START WITH comment_id = 9876 CONNECT BY PRIOR parent_id = comment_id;
CREATE TABLE Comments ( comment_id SERIAL PRIMARY KEY, path VARCHAR(1000), bug_id BIGINT UNSIGNED NOT NULL, author BIGINT UNSIGNED NOT NULL, comment_date DATETIME NOT NULL, comment TEXT NOT NULL, FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id), FOREIGN KEY (author) REFERENCES Accounts(account_id)
SELECT * FROM Comments AS c WHERE '1/4/6/7/' LIKE c.path || '%' ;
SELECT * FROM Comments AS c WHERE c.path LIKE '1/4/' || '%' ;
CREATE TABLE Comments ( comment_id SERIAL PRIMARY KEY, nsleft INTEGER NOT NULL, nsright INTEGER NOT NULL, bug_id BIGINT UNSIGNED NOT NULL, author BIGINT UNSIGNED NOT NULL, comment_date DATETIME NOT NULL, comment TEXT NOT NULL, FOREIGN KEY (bug_id) REFERENCES Bugs (bug_id), FOREIGN KEY (author) REFERENCES Accounts(account_id) );
شمارهی سمت چپ یک گره از تمام شمارههای سمت چپ فرزندان آن گره کوچکتر و شمارهی سمت راست آن گره از تمام شمارههای سمت راست آن گره بزرگتر است. این شمارهها هیچ ارتباطی به comment_id مربوط به آن گره ندارند.
یک راه حل ساده برای تخصیص این شمارهها به گرهها این است که از سمت چپ یک گره آغاز میکنیم و اولین شماره را اختصاص میدهیم و به همین به گرهای سمت چپ فرزندان میآییم و شمارهها را به صورت افزایشی به سمت چپ آنها نیز اختصاص میدهیم. سپس در ادامه به سمت راست آخرین نود رفته و از آن جا به سمت بالا میآییم و به همین ترتیب به صورت بازگشتی تخصیص شمارهها را ادامه میدهیم.
با اختصتص شمارهها به هر گره، میتوان از آنها برای یافتن نیاکان و فرزندان آن گره بهره جست. برای مثال برای بازیابی گرهی 4 و فرزندان (نوههای) آن باید دنبال گرههایی باشیم که شمارههای آن گرهها بین nsleft و nsright گرهی شماره4 باشد:
SELECT c2.* FROM Comments AS c1 JOIN Comments as c2 ON c2.nsleft BETWEEN c1.nsleft AND c1.nsright WHERE c1.comment_id = 4;
SELECT c2.* FROM Comments AS c1 JOIN Comment AS c2 ON c1.nsleft BETWEEN c2.nsleft AND c2.nsright WHERE c1.comment_id = 6;
SELECT parent.* FROM Comment AS c JOIN Comment AS parent ON c.nsleft BETWEEN parent.nsleft AND parent.nsright LEFT OUTER JOIN Comment AS in_between ON c.nsleft BETWEEN in_between.nsleft AND in_between.nsright AND in_between.nsleft BETWEEN parent.nsleft AND parent.nsright WHERE c.comment_id = 6 AND in_between.comment_id IS NULL;
-- make space for NS values 8 and 9 UPDATE Comment SET nsleft = CASE WHEN nsleft >= 8 THEN nsleft+2 ELSE nsleft END, nsright = nsright+2 WHERE nsright >= 7; -- create new child of comment #5, occupying NS values 8 and 9 INSERT INTO Comment (nsleft, nsright, author, comment) VALUES (8, 9, 'Fran' , 'Me too!' );
CREATE TABLE Comments ( comment_id SERIAL PRIMARY KEY, bug_id BIGINT UNSIGNED NOT NULL, author BIGINT UNSIGNED NOT NULL, comment_date DATETIME NOT NULL, comment TEXT NOT NULL, FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id), FOREIGN KEY (author) REFERENCES Accounts(account_id) ); CREATE TABLE TreePaths ( ancestor BIGINT UNSIGNED NOT NULL, descendant BIGINT UNSIGNED NOT NULL, PRIMARY KEY(ancestor, descendant), FOREIGN KEY (ancestor) REFERENCES Comments(comment_id), FOREIGN KEY (descendant) REFERENCES Comments(comment_id) );
به جای استفاده از جدول Comments برای ذخیرهی اطلاعات مربوط به یک درخت از جدول TreePath استفاده میکنیم. به ازای هر یک جفت گره در این درخت یک سطر در جدول ذخیره میشود که ارتباط پدر فرزندی را نمایش میدهد و الزاما نباید این دو پدر فرزند بلافصل باشد. همچنین یک سطر هم به ازای ارتباط هر گره با خودش به جدول اضافه میگردد.
پرسوجوهای بازیابی نیاکان و فرزندان (گرهها) از طریق جدول TreePaths سادهتر از روش مجموعههای تودرتو است. مثلا برای بازیابی فرزندان (نوههای) گرهی شمارهی 4، سطرهایی که نیاکان آنها 4 است را به دست میآوریم:
SELECT c.* FROM Comments AS c JOIN TreePaths AS t ON c.comment_id = t.descendant WHERE t.ancestor = 4;
SELECT c.* FROM Comments AS c JOIN TreePaths AS t ON c.comment_id = t.ancestor WHERE t.descendant = 6;
INSERT INTO TreePaths (ancestor, descendant) SELECT t.ancestor, 8 FROM TreePaths AS t WHERE t.descendant = 5 UNION ALL SELECT 8, 8;
DELETE FROM TreePaths WHERE descendant = 7;
DELETE FROM TreePaths WHERE descendant IN (SELECT descendant FROM TreePaths WHERE ancestor = 4);
DELETE FROM TreePaths WHERE descendant IN (SELECT descendant FROM TreePaths WHERE ancestor = 6) AND ancestor IN (SELECT ancestor FROM TreePaths WHERE descendant = 6 AND ancestor != descendant);
INSERT INTO TreePaths (ancestor, descendant) SELECT supertree.ancestor, subtree.descendant FROM TreePaths AS supertree CROSS JOIN TreePaths AS subtree WHERE supertree.descendant = 3 AND subtree.ancestor = 6;
میتوان عملکرد Closure Table را برای ایجاد پرسوجو روی فرزندان و پدر بلافصل را آسانتر نیز نمود. اگر فیلد path_length را به جدول TreePaths اضافه نماییم این کار انجام میشود. path_length گرهای که به خودش ارجاع میشود، صفر است. path_length فرزند بلافصل هر گره 1، path_length نوهی آن 2 میباشد و به همین ترتیب path_lengthها را در هر سطر مقداردهی میکنیم. اکنون یا فتن فرزند گرهی شمارهی 4 آسانتر است:
SELECT * FROM TreePaths WHERE ancestor = 4 AND path_length = 1;
اگر با SQL Server کار کرده باشید حتما با مفهوم و امکان Computed columns (فیلدهای محاسبه شده) آن آشنایی دارید. چقدر خوب میشد اگر این امکان برای سایر بانکهای اطلاعاتی که از تعریف فیلدهای محاسبه شده پشتیبانی نمیکنند، نیز مهیا میشد. زیرا یکی از اهداف مهم استفادهی صحیح از ORMs ، مستقل شدن برنامه از نوع بانک اطلاعاتی است. برای مثال امروز میخواهیم با MySQL کار کنیم، ماه بعد شاید بخواهیم یک نسخهی سبکتر مخصوص کار با SQLite را ارائه دهیم. آیا باید قسمت دسترسی به داده برنامه را از نو بازنویسی کرد؟ اینکار در NHibernate فقط با تغییر نحوهی اتصال به بانک اطلاعاتی میسر است و نه بازنویسی کل برنامه (و صد البته شرط مهم و اصلی آن هم این است که از امکانات ذاتی خود NHibernate استفاده کرده باشید. برای مثال وسوسهی استفاده از رویههای ذخیره شده را فراموش کرده و به عبارتی ORM مورد استفاده را به امکانات ویژهی یک بانک اطلاعاتی گره نزده باشید).
خوشبختانه در NHibernate امکان تعریف فیلدهای محاسباتی با کمک تعریف نگاشت خواص به صورت فرمول مهیا است. برای توضیحات بیشتر لطفا به مثال ذیل دقت بفرمائید:
در ابتدا کلاس کاربر تعریف میشود:
using System;
using NHibernate.Validator.Constraints;
namespace FormulaTests.Domain
{
public class User
{
public virtual int Id { get; set; }
[NotNull]
public virtual DateTime JoinDate { set; get; }
[NotNullNotEmpty]
[Length(450)]
public virtual string FirstName { get; set; }
[NotNullNotEmpty]
[Length(450)]
public virtual string LastName { get; set; }
[Length(900)]
public virtual string FullName { get; private set; } //از طریق تعریف فرمول مقدار دهی میگردد
public virtual int DayOfWeek { get; private set; }//از طریق تعریف فرمول مقدار دهی میگردد
}
}
using FluentNHibernate.Automapping;
using FluentNHibernate.Automapping.Alterations;
namespace FormulaTests.Domain
{
public class UserCustomMappings : IAutoMappingOverride<User>
{
public void Override(AutoMapping<User> mapping)
{
mapping.Id(u => u.Id).GeneratedBy.Identity(); //ضروری است
mapping.Map(x => x.DayOfWeek).Formula("DATEPART(dw, JoinDate) - 1");
mapping.Map(x => x.FullName).Formula("FirstName + ' ' + LastName");
}
}
}
اکنون اگر Fluent NHibernate را وادار به تولید اسکریپت متناظر با این دو کلاس کنیم حاصل به صورت زیر خواهد بود:
create table Users (
UserId INT IDENTITY NOT NULL,
JoinDate DATETIME not null,
FirstName NVARCHAR(450) not null,
LastName NVARCHAR(450) not null,
primary key (UserId)
)
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2"
default-access="property" auto-import="true" default-cascade="none" default-lazy="true">
<class xmlns="urn:nhibernate-mapping-2.2" mutable="true"
name="FormulaTests.Domain.User, FormulaTests, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" table="Users">
<id name="Id" type="System.Int32" unsaved-value="0">
<column name="UserId" />
<generator class="identity" />
</id>
<property name="DayOfWeek" formula="DATEPART(dw, JoinDate) - 1" type="System.Int32" />
<property name="FullName" formula="FirstName + ' ' + LastName" type="System.String" />
<property name="JoinDate" type="System.DateTime">
<column name="JoinDate" />
</property>
<property name="FirstName" type="System.String">
<column name="FirstName" />
</property>
<property name="LastName" type="System.String">
<column name="LastName" />
</property>
</class>
</hibernate-mapping>
var list = session.Query<User>.ToList();
foreach (var item in list)
{
Console.WriteLine("{0}:{1}", item.FullName, item.DayOfWeek);
}
select
user0_.UserId as UserId0_,
user0_.JoinDate as JoinDate0_,
user0_.FirstName as FirstName0_,
user0_.LastName as LastName0_,
DATEPART(user0_.dw, user0_.JoinDate) - 1 as formula0_, --- همان فرمول تعریف شده است
user0_.FirstName + ' ' + user0_.LastName as formula1_ ---از طریق فرمول تعریف شده حاصل گردیده است
from
Users user0_
تمرین 1 - ایجاد فرم ثبت نام
میخواهیم به برنامه، فرم ثبت نام را که حاوی سه فیلد نام کاربری، کلمهی عبور و نام است، اضافه کنیم. نام کاربری باید از نوع ایمیل باشد. بنابراین اعتبارسنجی مرتبطی نیز باید برای این فیلد تعریف شود. کلمهی عبور وارد شده باید حداقل 5 حرف باشد. همچنین تا زمانیکه اعتبارسنجی فرم تکمیل نشدهاست، باید دکمهی submit فرم، غیرفعال باقی بماند. لینک ورود به این فرم نیز باید به منوی راهبری سایت اضافه شود.
برای حل این تمرین، فایل جدید registerForm.jsx را در پوشهی components ایجاد میکنیم و سپس توسط میانبرهای imrc و cc در VSCode، ساختار ابتدایی کامپوننت RegisterForm را ایجاد کرده و سپس آنرا به صورت زیر تکمیل میکنیم:
- ابتدا در فایل app.js، پس از import ماژول آن:
import RegisterForm from "./components/registerForm";
<Route path="/register" component={RegisterForm} />
<NavLink className="nav-item nav-link" to="/register"> Register </NavLink>
import Joi from "@hapi/joi"; import React from "react"; import Form from "./common/form"; class RegisterForm extends Form { state = { data: { username: "", password: "", name: "" }, errors: {} }; schema = { username: Joi.string() .required() .email({ minDomainSegments: 2, tlds: { allow: ["com", "net"] } }) .label("Username"), password: Joi.string() .required() .min(5) .label("Password"), name: Joi.string() .required() .label("Name") }; doSubmit = () => { // Call the server console.log("Submitted"); }; render() { return ( <div> <h1>Register</h1> <form onSubmit={this.handleSubmit}> {this.renderInput("username", "Username")} {this.renderInput("password", "Password", "password")} {this.renderInput("name", "Name")} {this.renderButton("Register")} </form> </div> ); } } export default RegisterForm;
- سپس state این کامپوننت را با شیءای حاوی دو خاصیت data و error، مقدار دهی اولیه میکنیم. خواص متناظر با المانهای فرم را نیز به صورت یک شیء، به خاصیت data انتساب دادهایم.
- پس از آن، خاصیت schema تعریف شدهاست؛ تا قواعد اعتبارسنجی تک تک فیلدهای فرم را به کمک کتابخانهی Joi، مطابق نیازمندیهایی که در ابتدای تعریف این تمرین مشخص کردیم، ایجاد کند.
- در ادامه، متد doSubmit را ملاحظه میکنید. این متد پس از کلیک بر روی دکمهی Register و پس از اعتبارسنجی موفقیت آمیز فرم، به صورت خودکار فراخوانی میشود.
- در آخر، تعریف فرم ثبتنام را مشاهده میکنید که نکات آنرا در قسمت قبل، با معرفی کامپوننت Form و افزودن متدهای کمکی رندر input و button به آن، بررسی کردیم و در کل با نکات بررسی شدهی در فرم لاگینی که تا به اینجا ایجاد کردیم، تفاوتی ندارد.
تمرین 2- ایجاد فرم ثبت و یا ویرایش یک فیلم
فرم جدید ثبت و ویرایش یک فیلم، نکات بیشتری را به همراه دارد. در اینجا میخواهیم در بالای لیست نمایش فیلمها، یک دکمهی new movie را اضافه کنیم تا با کلیک بر روی آن، به فرم ثبت و ویرایش فیلمها هدایت شویم. این فرم، از فیلدهای یک عنوان متنی، انتخاب ژانر از یک drop down list، تعداد موجود (بین 1 و 100) و امتیاز (بین صفر تا 10) تشکیل شدهاست. همچنین تا زمانیکه اعتبارسنجی فرم تکمیل نشدهاست، دکمهی submit فرم باید غیرفعال باقی بماند. پس از ذخیره شدن این فیلم (در لیست درون حافظهای برنامه)، با مراجعهی به لیست فیلمها و انتخاب آن از لیست (با کلیک بر روی لینک آن)، باید مجددا به همین فرم، در حالت ویرایش این رکورد هدایت شویم. به علاوه اگر در بالای صفحه یک id اشتباه وارد شد، باید صفحهی «پیدا نشد» نمایش داده شود.
کامپوننت MovieForm و مسیریابی آنرا در قسمت 17، تعریف و اضافه کردیم. برای تعریف لینکی به آن، به کامپوننت movies مراجعه کرده و بالای متنی که تعداد کل آیتمهای موجود در بانک اطلاعاتی را نمایش میدهد، المان زیر را اضافه میکنیم:
import { Link } from "react-router-dom"; // ... <div className="col"> <Link to="/movies/new" className="btn btn-primary" style={{ marginBottom: 20 }} > New Movie </Link> <p>Showing {totalCount} movies in the database.</p>
سپس به کامپوننت src\components\movieForm.jsx که پیشتر آنرا اضافه کرده بودیم، مراجعه کرده و به صورت زیر آنرا تکمیل میکنیم:
import Joi from "@hapi/joi"; import React from "react"; import { getGenres } from "../services/fakeGenreService"; import { getMovie, saveMovie } from "../services/fakeMovieService"; import Form from "./common/form"; class MovieForm extends Form { state = { data: { title: "", genreId: "", numberInStock: "", dailyRentalRate: "" }, genres: [], errors: {} };
- سپس این کامپوننت نیز از کامپوننت Form ارث بری میکند تا به امکانات ویژهی آن دسترسی پیدا کند.
- در ادامه در خاصیت state، طبق روالی که در کامپوننت فرم درنظر گرفتهایم، دو خاصیت data و errors باید حضور داشته باشند. در خاصیت data، شیءای که نام خاصیتهای آن با فیلدهای فرم تطابق دارد، ذکر شدهاند. در اینجا برای ذخیره سازی اطلاعات انتخاب شدهی از drop down list مرتبط با ژانرهای سینمایی، از خاصیت genreId استفاده میشود؛ این تنها اطلاعاتی است که از کل آیتمهای یک drop down list نیاز داریم. آرایهی genres که آیتمهای این drop down list را مقدار دهی میکند، در روال componentDidMount، از سرویس مرتبطی دریافت و مقدار دهی خواهد شد.
در ادامهی کدهای کامپوننت MovieForm، کدهای schema اعتبارسنجی شیء data را ملاحظه میکنید:
schema = { _id: Joi.string(), title: Joi.string() .required() .label("Title"), genreId: Joi.string() .required() .label("Genre"), numberInStock: Joi.number() .required() .min(0) .max(100) .label("Number in Stock"), dailyRentalRate: Joi.number() .required() .min(0) .max(10) .label("Daily Rental Rate") };
اکنون به مرحلهی componentDidMount میرسیم:
componentDidMount() { const genres = getGenres(); this.setState({ genres }); const movieId = this.props.match.params.id; if (movieId === "new") return; const movie = getMovie(movieId); if (!movie) return this.props.history.replace("/not-found"); this.setState({ data: this.mapToViewModel(movie) }); }
- پس از آن، نحوهی دریافت پارامتر id مسیریابی رسیده را ملاحظه میکنید. این id اگر به "new" تنظیم شده بود، یعنی قرار است، اطلاعات جدیدی ثبت شوند. بنابراین متد جاری را خاتمه میدهیم (چون کار ادامهی این متد، مقدار دهی اولیهی تمام فیلدهای فرم، بر اساس اطلاعات شیء دریافت شدهی از سرویس فیلمها است). در غیراینصورت (و با مشخص بودن id)، با استفاده از این id و متد getMovie سرویس src\services\fakeMovieService.js، سعی خواهیم کرد تا اطلاعات شیء movie متناظری را دریافت کنیم. اگر خروجی این متد null بود، یعنی id وارد شده معتبر نیست. به همین جهت کاربر را به صفحهی not-found هدایت میکنیم. اگر دقت کنید در اینجا بجای متد push، از متد replace استفاده کردهایم. چون اگر از متد push استفاده میکردیم و کاربر بر روی دکمهی back مرورگر کلیک میکرد، دوباره به همین صفحه، با id غیرمعتبر قبلی وارد میشد و یک حلقهی بیپایان رخ میداد. همچنین به return ای هم که به همراه متد replace استفاده شده، دقت کنید. کار redirect به یک صفحهی دیگر، به معنای عدم اجرای کدهای پس از آن نیست. بنابراین اگر میخواهیم کار این متد با redirect، به پایان برسد، ذکر return الزامی است.
- در پایان این متد، خاصیت data موجود در state را به روز رسانی میکنیم؛ تا سبب رندر فرم، با اطلاعات شیء movie یافت شده گردد و چون ساختار شیء movie دریافت شدهی از سرویس، با ساختار data تعریف شدهی در state یکی نیست، نیاز به نگاشت این دو به هم، توسط متد سفارشی mapToViewModel زیر است:
mapToViewModel(movie) { return { _id: movie._id, title: movie.title, genreId: movie.genre._id, numberInStock: movie.numberInStock, dailyRentalRate: movie.dailyRentalRate }; }
در ادامهی کدهای کامپوننت فرم فیلمها، به متد doSubmit میرسیم:
doSubmit = () => { saveMovie(this.state.data); this.props.history.push("/movies"); };
در انتهای این کامپوننت نیز به متد رندر آن میرسیم:
render() { return ( <div> <h1>Movie Form</h1> <form onSubmit={this.handleSubmit}> {this.renderInput("title", "Title")} {this.renderSelect("genreId", "Genre", this.state.genres)} {this.renderInput("numberInStock", "Number in Stock", "number")} {this.renderInput("dailyRentalRate", "Rate")} {this.renderButton("Save")} </form> </div> ); }
برای تعریف متد جدید renderSelect به این صورت عمل میکنیم:
- ابتدا فایل جدید src\components\common\select.jsx را ایجاد کرده و سپس آنرا جهت نمایش یک drop down list، ویرایش میکنیم:
import React from "react"; const Select = ({ name, label, options, error, ...rest }) => { return ( <div className="form-group"> <label htmlFor={name}>{label}</label> <select name={name} id={name} {...rest} className="form-control"> <option value="" /> {options.map(option => ( <option key={option._id} value={option._id}> {option.name} </option> ))} </select> {error && <div className="alert alert-danger">{error}</div>} </div> ); }; export default Select;
- پس از آن به کامپوننت src\components\common\form.jsx مراجعه کرده و متد رندر آنرا اضافه میکنیم:
import Select from "./select"; // ... class Form extends Component { // ... renderSelect(name, label, options) { const { data, errors } = this.state; return ( <Select name={name} value={data[name]} label={label} options={options} onChange={this.handleChange} error={errors[name]} /> ); } }
تمرین 3- جستجوی در لیست فیلمها
میخواهیم در بالای لیست نمایش فیلمها، یک search box را قرار دهیم تا توسط آن بتوان بر اساس عنوان وارد شده، در فیلمهای موجود جستجو کرد. همچنین این جستجو قرار است کلی بوده و حتی در صورت انتخاب ژانر خاصی از منوی کنار صفحه، باید در کل اطلاعات موجود جستجو کند. به علاوه اگر کاربر ژانری را انتخاب کرد، این text box باید خالی شود.
برای اینکار ابتدا فایل جدید src\components\searchBox.jsx را ایجاد کرده و به صورت زیر آنرا تکمیل میکنیم:
import React from "react"; const SearchBox = ({ value, onChange }) => { return ( <input type="text" name="query" className="form-control my-3" placeholder="Search..." value={value} onChange={e => onChange(e.currentTarget.value)} /> ); }; export default SearchBox;
سپس به کامپوننت movies مراجعه کرده و آنرا ذیل متن نمایش تعداد رکوردها، درج میکنیم:
<p>Showing {totalCount} movies in the database.</p> <SearchBox value={searchQuery} onChange={this.handleSearch} />
import SearchBox from "./searchBox"; //... class Movies extends Component { state = { //... selectedGenre: {}, searchQuery: "" }; handleSearch = query => { this.setState({ searchQuery: query, selectedGenre: null, currentPage: 1 }); }; handleGenreSelect = genre => { console.log("handleGenreSelect", genre); this.setState({ selectedGenre: genre, searchQuery: "", currentPage: 1 }); };
متد handleGenreSelect را نیز اندکی تغییر دادهایم تا اگر گروهی انتخاب شد، مقدار searchQuery را خالی کند. اگر در اینجا searchQuery را به نال تنظیم میکردیم، controlled component جعبهی جستجو، تبدیل به کامپوننت کنترل نشدهای میشد و در این حالت، React، اخطار تبدیل بین این دو را صادر میکرد.
در آخر، ابتدای متد getPageData هم جهت اعمال searchQuery، به صورت زیر تغییر میکند:
getPagedData() { const { pageSize, currentPage, selectedGenre, movies: allMovies, sortColumn, searchQuery } = this.state; let filteredMovies = allMovies; if (searchQuery) { filteredMovies = allMovies.filter(m => m.title.toLowerCase().startsWith(searchQuery.toLowerCase()) ); } else if (selectedGenre && selectedGenre._id) { filteredMovies = allMovies.filter(m => m.genre._id === selectedGenre._id); }
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید: sample-21.zip
وبلاگها و سایتهای ایرانی
Visual Studio
امنیت
- رمزنگاری کوانتمی و شبکهای رمزنگاری شده با این روش
ASP. Net
طراحی وب
اسکیوال سرور
به روز رسانیها
ابزارها
- نگارش جدید برنامه RSS Bandit . (برنامهای سورس باز نوشته شده با سی شارپ)
- Visual Round Trip Analyzer (استفاده از NetMon API)
سیشارپ
- Interactive GUI Shell از توسعه دهندگان تیم Mono
عمومی دات نت
- آشنایی با کلاس CommaDelimitedStringCollection در دات نت فریم ورک
- مونو و دات نت گزارشی از PDC2008
CPP
دلفی
- CompilerPlugin برای دلفی 2009. (توسط آن میتوان پروژههای دلفی 2007 را با دلفی 2009 نیز کامپایل کرد)
- نسخه جدید CnPack منتشر شد. (با پشتیبانی دلفی 5 تا 2009)
ویندوز
- آنالیز crash dumps ویندوز . (آیا میدانید صفحات آبی ویندوز را چگونه باید تفسیر کرد؟)
Office
- آشنایی با یک سری از اصطلاحات outlook 2007 برای برنامه نویسها. (اگر قصد داشته باشید یک add-in را برای outlook 2007 با استفاده از امکانات VSTO توسعه دهید، آشنایی با این اصطلاحات بسیار ضروری خواهد بود)
متفرقه
طراحی فرم ثبت نام کاربران در سایت با روش model driven
در این قسمت قصد داریم فرم ثبت نام کاربران را به همراه اعتبارسنجیهای پیشرفتهای پیاده سازی کنیم. به همین منظور، ابتدا پوشهی جدید App\users را به مثال سری جاری اضافه کنید و سپس سه فایل user.ts، signup-form.component.ts و signup-form.component.html را به آن اضافه نمائید.
فایل user.ts بیانگر مدل کاربران سایت است؛ با این محتوا:
export interface IUser { id: number; name: string; email: string; password: string; }
قالب فرم یا signup-form.component.html، در حالت ابتدایی آن چنین شکل استانداردی را خواهد داشت و فاقد اعتبارسنجی خاصی است:
<form> <div class="form-group"> <label form="name">Username</label> <input id="name" type="text" class="form-control" /> </div> <div class="form-group"> <label form="email">Email</label> <input id="email" type="text" class="form-control" /> </div> <div class="form-group"> <label form="password">Password</label> <input id="password" type="password" class="form-control" /> </div> <button class="btn btn-primary" type="submit">Submit</button> </form>
بنابراین ابتدا کلاس کامپوننت این فرم را در فایل signup-form.component.ts به نحو ذیل تکمیل کنید:
import { Component } from '@angular/core'; import { Control, ControlGroup, Validators } from '@angular/common'; @Component({ selector: 'signup-form', templateUrl: 'app/users/signup-form.component.html' }) export class SignupFormComponent { form = new ControlGroup({ name: new Control('', Validators.required), email: new Control('', Validators.required), password: new Control('', Validators.required) }); onSubmit(): void { console.log(this.form.value); } }
<form [ngFormModel]="form" (ngSubmit)="onSubmit()"> <div class="form-group"> <label form="name">Username</label> <input id="name" type="text" class="form-control" ngControl="name"/> <label class="text-danger" *ngIf="!form.controls['name'].valid"> Username is required. </label> </div> <div class="form-group"> <label form="email">Email</label> <input id="email" type="text" class="form-control" ngControl="email" #email="ngForm"/> <label class="text-danger" *ngIf="email.touched && !email.valid"> Email is required. </label> </div> <div class="form-group"> <label form="password">Password</label> <input id="password" type="password" class="form-control" ngControl="password" #password="ngForm"/> <label class="text-danger" *ngIf="password.touched && !password.valid"> Password is required. </label> </div> <button class="btn btn-primary" type="submit">Submit</button> </form>
تفاوت مهم این فرم و اعتبارسنجیهایش با قسمت قبل، در ایجاد اشیاء Control و ControlGroup به صورت صریح است:
form = new ControlGroup({ name: new Control('', Validators.required), email: new Control('', Validators.required), password: new Control('', Validators.required) });
import { Control, ControlGroup, Validators } from '@angular/common';
یک نکته
اگر محل قرارگیری کلاسی را فراموش کردید، آنرا در مستندات AngularJS 2.0 ذیل قسمت API Review جستجو کنید. نتیجهی جستجو، به همراه نام ماژول کلاسها نیز میباشد.
خاصیت عمومی form که با new ControlGroup تعریف شدهاست، حاوی تعاریف صریح کنترلهای موجود در فرم خواهد بود. در اینجا سازندهی ControlGroup، یک شیء را میپذیرد که کلیدهای آن، همان نام کنترلهای تعریف شدهی در قالب فرم و مقدار هر کدام، یک Control جدید است که پارامتر اول آن یک مقدار پیش فرض و پارامتر دوم، اعتبارسنجی مرتبطی را تعریف میکند (این اعتبارسنجی معرفی شده، یک متد استاتیک در کلاس توکار Validators است).
بنابراین چون سه المان ورودی، در فرم جاری تعریف شدهاند، سه کلید جدید هم نام نیز در پارامتر ورودی ControlGroup ذکر گردیدهاند.
اکنون که خاصیت عمومی form، در کلاس کامپوننت فوق تعریف شد، آنرا در قالب فرم، به ngFormModel بایند میکنیم:
<form [ngFormModel]="form" (ngSubmit)="onSubmit()">
مراحل بعد آن، با مراحلی که در قسمت قبل بررسی کردیم، تفاوتی ندارند:
الف) در اینجا به هر المان موجود، یک ngControl نسبت داده شدهاست تا هر المان را تبدیل به یک کنترل AngularJS2 2.0 کند.
ب) به هر المان، یک متغیر محلی شروع شده با # نسبت داده شدهاست تا با اتصال آن به ngForm بتوان به ngControl تعریف شده دسترسی پیدا کرد.
البته اکنون میتوان از خاصیت form متصل به ngFormModel نیز بجای تعریف این متغیر محلی، به نحو ذیل استفاده کرد:
<label class="text-danger" *ngIf="!form.controls['name'].valid">
د) و در آخر متد onSumbit موجود در کلاس کامپوننت را به رخداد ngSubmit متصل کردهایم. همانطور که ملاحظه میکنید اینبار دیگر پارامتری را به آن ارسال نکردهایم. از این جهت که خاصیت form موجود در سطح کلاس، اطلاعات کاملی را از اشیاء موجود در آن دارد و در متد onSubmit کلاس، به آن دسترسی داریم.
onSubmit(): void { console.log(this.form.value); }
بنابراین تا اینجا تنها تفاوت فرم جدید تعریف شده با قسمت قبل، تعریف صریح ControlGroup و کنترلهای آن در کلاس کامپوننت و اتصال آن به ngFormModel است. به این نوع فرمها، فرمهای model driven هم میگویند.
نمایش فرم افزودن کاربران توسط سیستم Routing
با نحوهی تعریف مسیریابیها در قسمت نهم آشنا شدیم. برای نمایش فرم افزودن کاربران، میتوان تغییرات ذیل را به فایل app.component.ts اعمال کرد:
//same as before... import { SignupFormComponent } from './users/signup-form.component'; @Component({ //same as before… template: ` //same as before… <li><a [routerLink]="['AddUser']">Add User</a></li> //same as before… `, //same as before… }) @RouteConfig([ //same as before… { path: '/adduser', name: 'AddUser', component: SignupFormComponent } ]) //same as before...
معرفی کلاس FormBuilder
روش دیگری نیز برای ساخت ControlGroup و کنترلهای آن با استفاده از کلاس و سرویس فرم ساز توکار AngularJS 2.0 وجود دارد:
import { Control, ControlGroup, Validators, FormBuilder } from '@angular/common'; form: ControlGroup; constructor(formBuilder: FormBuilder) { this.form = formBuilder.group({ name: ['', Validators.required], email: ['', Validators.required], password: ['', Validators.required] }); }
پیاده سازی اعتبارسنجی سفارشی
فرض کنید میخواهیم ورود نام کاربرهای دارای فاصله را غیر معتبر اعلام کنیم. برای این منظور فایل جدید usernameValidators.ts را به پوشهی app\users اضافه کنید؛ با این محتوا:
import { Control } from '@angular/common'; export class UsernameValidators { static cannotContainSpace(control: Control) { if (control.value.indexOf(' ') >= 0) { return { cannotContainSpace: true }; } return null; } }
هر متد پیاده سازی کنندهی یک اعتبار سنجی سفارشی در این کلاس، استاتیک تعریف میشود؛ با نام دلخواهی که مدنظر است.
پارامتر ورودی این متدهای استاتیک، یک وهله از شیء کنترل است که توسط آن میتوان برای مثال به خاصیت value آن دسترسی یافت و بر این اساس منطق اعتبارسنجی خود را پیاده سازی نمود. به همین جهت import آن نیز به ابتدای فایل جاری اضافه شدهاست.
خروجی این متد دو حالت دارد:
الف) اگر null باشد، یعنی اعتبارسنجی موفقیت آمیز بودهاست.
ب) اگر اعتبارسنجی با شکست مواجه شود، خروجی این متد یک شیء خواهد بود که کلید آن، نام اعتبارسنجی مدنظر است و مقدار این کلید، هر چیزی میتواند باشد؛ یک true و یا یک شیء دیگر که اطلاعات بیشتری را در مورد این شکست ارائه دهد.
برای مثال اگر اعتبارسنج توکار required با شکست مواجه شود، یک چنین شیءایی را بازگشت میدهد:
{ required:true }
{ minlength : { requiredLength : 3, actualLength : 1 } }
پس از پیاده سازی یک اعتبارسنجی سفارشی، برای استفادهی از آن، ابتدا ماژول آنرا به ابتدای ماژول signup-form.component.ts اضافه میکنیم:
import { UsernameValidators } from './usernameValidators';
name: ['', Validators.compose([Validators.required, UsernameValidators.cannotContainSpace])],
و مرحلهی آخر، نمایش یک پیام اعتبارسنجی مناسب و متناظر با متد cannotContainSpace است. برای این منظور فایل signup-form.component.html را گشوده و تغییرات ذیل را اعمال کنید:
<div class="form-group"> <label form="name">Username</label> <input id="name" type="text" class="form-control" ngControl="name" #name="ngForm" /> <div *ngIf="name.touched && name.errors"> <label class="text-danger" *ngIf="name.errors.required"> Username is required. </label> <label class="text-danger" *ngIf="name.errors.cannotContainSpace"> Username can't contain space. </label> </div> </div>
نام خاصیت بازگشت داده شدهی در اعتبارسنجی سفارشی، به عنوان یک خاصیت جدید شیء errors قابل استفاده است؛ مانند name.errors.cannotContainSpace.
به عنوان تمرین ماژول جدید emailValidators.ts را افزوده و سپس اعتبارسنجی سفارشی بررسی معتبر بودن ایمیل وارد شده را تعریف کنید:
import {Control} from '@angular/common'; export class EmailValidators { static email(control: Control) { var regEx = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; var valid = regEx.test(control.value); return valid ? null : { email: true }; } }
یک نکته
اگر نیاز است از regular expressions مانند مثال فوق استفاده شود، میتوان از متد توکار Validators.pattern نیز استفاده کرد و نیازی به تعریف یک متد جداگانه برای آن وجود ندارد؛ مگر اینکه نیاز به بازگشت شیء خطای کاملتری با خواص بیشتری وجود داشته باشد.
اعتبارسنجی async یا اعتبارسنجی از راه دور (remote validation)
یک سری از اعتبارسنجیها را در سمت کلاینت میتوان تکمیل کرد؛ مانند بررسی معتبر بودن فرمت ایمیل وارده. اما تعدادی دیگر، نیاز به اطلاعاتی از سمت سرور دارند. برای مثال آیا نام کاربری در حال ثبت، تکراری است یا خیر؟ این نوع اعتبارسنجیها در ردهی async validation قرار میگیرند.
سازندهی شیء Control در AngularJS 2.0 که در مثالهای بالا نیز مورد استفاده قرار گرفت، پارامتر اختیاری سومی را نیز دارد که یک AsyncValidatorFn را قبول میکند:
control(value: Object, validator?: ValidatorFn, asyncValidator?: AsyncValidatorFn) : Control
nameShouldBeUnique(control: Control) { let name: string = control.value; return new Promise((resolve) => { this._userService.isUserNameUnique(<IUser>{ "name": name }).subscribe( (result: IResult) => { resolve( result.result ? null : { 'nameShouldBeUnique': true } ); }, error => { resolve(null); } ); }); }
this.form = _formBuilder.group({ name: ['', Validators.compose([ Validators.required, UsernameValidators.cannotContainSpace ]), (control: Control) => this.nameShouldBeUnique(control)],
امضای متد nameShouldBeUnique تفاوتی با سایر متدهای اعتبارسنج نداشته و پارامتر ورودی آن، همان کنترل است که توسط آن میتوان به مقدار وارد شدهی توسط کاربر دسترسی یافت. اما تفاوت اصلی آن در اینجا است که این متد باید یک شیء Promise را بازگشت دهد. یک Promise، بیانگر نتیجهی یک عملیات async است. در اینجا دو حالت resolve و error را باید پیاده سازی کرد. در حالت error، یعنی عملیات async صورت گرفته با شکست مواجه شدهاست و در حالت resolve، یعنی عملیات تکمیل شده و اکنون میخواهیم نتیجهی نهایی را بازگشت دهیم. نتیجه نهایی بازگشت داده شدهی در اینجا، همانند سایر validators است و اگر نال باشد، یعنی اعتبارسنجی موفقیت آمیز بوده و اگر یک شیء را بازگشت دهیم، یعنی اعتبارسنجی با شکست مواجه شدهاست.
این Promise، از یک سرویس تعریف شدهی در فایل جدید user.service.ts استفاده میکند:
import { Injectable } from '@angular/core'; import { Http, Response } from '@angular/http'; import { Observable } from 'rxjs/Observable'; import { Headers, RequestOptions } from '@angular/http'; import { IUser } from './user'; import { IResult } from './result'; @Injectable() export class UserService { private _checkUserUrl = '/home/checkUser'; constructor(private _http: Http) { } private handleError(error: Response) { console.error(error); return Observable.throw(error.json().error || 'Server error'); } isUserNameUnique(user: IUser): Observable<IResult> { let headers = new Headers({ 'Content-Type': 'application/json' }); // for ASP.NET MVC let options = new RequestOptions({ headers: headers }); return this._http.post(this._checkUserUrl, JSON.stringify(user), options) .map((response: Response) => <IResult>response.json()) .do(data => console.log("User: " + JSON.stringify(data))) .catch(this.handleError); } }
export interface IResult { result: boolean; }
کدهای سمت سرور برنامه که کار بررسی یکتا بودن نام کاربری را انجام میدهند، به صورت ذیل در فایل Controllers\HomeController.cs تعریف شدهاند:
namespace MVC5Angular2.Controllers { public class HomeController : Controller { [HttpPost] public ActionResult CheckUser(User user) { var isUnique = new { result = true }; if (user.Name?.Equals("Vahid", StringComparison.OrdinalIgnoreCase) ?? false) { isUnique = new { result = false }; } return new ContentResult { Content = JsonConvert.SerializeObject(isUnique, new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }), ContentType = "application/json", ContentEncoding = Encoding.UTF8 }; } } }
بنابراین تا اینجا مسیر سمت سرور home/checkuser تکمیل شدهاست. این مسیر توسط سرویس کاربر صدا زده شده و اگر نام کاربری وارد شده موجود باشد، نتیجهای را مطابق امضای قرارداد IResult سفارشی ما بازگشت میدهد.
پس از آن مجددا به فایل signup-form.component.ts مراجعه کرده و سرویس جدید UserService را به سازندهی آن تزریق کردهایم. همچنین قسمت providers این کامپوننت را هم جهت تکمیل اطلاعات تزریق کنندهی توکار AngularJS 2.0 مقدار دهی کردهایم. البته همانطور که در مبحث تزریق وابستگیها نیز عنوان شد، اگر این سرویس قرار نیست در کلاس دیگری استفاده شود، نیازی نیست تا آنرا در بالاترین سطح ممکن و در فایل app.component.ts ثبت و معرفی کرد:
@Component({ selector: 'signup-form', templateUrl: 'app/users/signup-form.component.html', providers: [ UserService ] }) export class SignupFormComponent { constructor(private _formBuilder: FormBuilder, private _userService: UserService) {
اکنون که کدهای فعال سازی اعتبارسنجی از راه دور ما تکمیل شدهاست، به فایل signup-form.component.html مراجعه کرده و پیام مناسبی را نمایش خواهیم داد:
<div *ngIf="name.touched && name.errors"> <label class="text-danger" *ngIf="name.errors.required"> Username is required. </label> <label class="text-danger" *ngIf="name.errors.cannotContainSpace"> Username can't contain space. </label> <label class="text-danger" *ngIf="name.errors.nameShouldBeUnique"> This username is already taken. </label> </div>
نمایش پیام loading در حین انجام اعتبارسنجی از راه دور
شاید بد نباشد که در حین انجام عملیات اعتبارسنجی از راه دور و ارسال درخواستی به سرور و بازگشت نتیجهی آن، یک پیام loading را نیز نمایش داد. برای انجام اینکار نیاز است تغییرات ذیل را به فایل signup-form.component.html اضافه کنیم:
<input id="name" type="text" class="form-control" ngControl="name" #name="ngForm" /> <div *ngIf="name.control.pending"> Checking server, Please wait ... </div>
اعتبارسنجی ترکیبی در حین submit یک فرم
فرض کنید میخواهید منطقی را که حاصل اعتبارسنجی تمام فیلدهای فرم است (و نه هر کدام به تنهایی)، در حین submit آن اعمال کنید. برای مثال آیا ترکیب نام کاربری و کلمهی عبور شخصی در حین login معتبر است یا خیر؟ در این حالت پس از بررسیهای لازم در متد onSubmit، میتوان با استفاده از متد find شیء form، به یکی از کنترلهای فرم دسترسی یافت و سپس با استفاده از متد setErrors، خطای اعتبارسنجی سفارشی را به آن اضافه کرد:
onSubmit(): void { console.log(this.form.value); this.form.find('name').setErrors({ invalidData : true }); }
<div *ngIf="name.touched && name.errors"> <label class="text-danger" *ngIf="name.errors.invalidData"> Check the inputs.... </label> </div>
اتصال المانهای فرم به مدلی جهت ارسال به سرور
اکنون که دسترسی به خاصیت this.form را داریم و این خاصیت توسط [ngFormModel] به تمام اشیاء تعریف شدهی در فرم و تغییرات آنها دسترسی دارد، میتوان از آن برای دسترسی به شیءایی که حاوی مدل فرم است، استفاده کرد. برای نمونه در مثال فوق، خاصیت value آن، چنین خروجی را دارد:
{ name="VahidN", email="email@site.com", password="123"}
import { Injectable } from '@angular/core'; import { Http, Response } from '@angular/http'; import { Observable } from 'rxjs/Observable'; import { Headers, RequestOptions } from '@angular/http'; import { IUser } from './user'; import { IResult } from './result'; @Injectable() export class UserService { private _addUserUrl = '/home/addUser'; constructor(private _http: Http) { } private handleError(error: Response) { console.error(error); return Observable.throw(error.json().error || 'Server error'); } addUser(user: IUser): Observable<IUser> { let headers = new Headers({ 'Content-Type': 'application/json' }); // for ASP.NET MVC let options = new RequestOptions({ headers: headers }); return this._http.post(this._addUserUrl, JSON.stringify(user), options) .map((response: Response) => <IUser>response.json()) .do(data => console.log("User: " + JSON.stringify(data))) .catch(this.handleError); } }
[HttpPost] public ActionResult AddUser(User user) { user.Id = 1; //todo: save user and get id from db return new ContentResult { Content = JsonConvert.SerializeObject(user, new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }), ContentType = "application/json", ContentEncoding = Encoding.UTF8 }; }
onSubmit(): void { console.log(this.form.value); /*this.form.find('name').setErrors({ invalidData : true });*/ this._userService.addUser(<IUser>this.form.value) .subscribe((user: IUser) => { console.log(`ID: ${user.id}`); }); }
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید: (این کدها مطابق نگارش RC 1 هستند)
MVC5Angular2.part11.zip
خلاصهی بحث
برای اینکه بتوان کنترل بیشتری را بر روی المانهای فرم داشت، ابتدا سرویس FormBuilder را در سازندهی کلاس کامپوننت فرم تزریق میکنیم. سپس با استفاده از متد group آن، المانهای فرم را به صورت کلیدهای شیء پارامتر آن تعریف میکنیم. در اینجا میتوان اعتبارسنجیهای توکار AngularJS 2.0 را که در کلاس پایهی Validators مانند Validators.required وجود دارند، تعریف کرد. با استفاده از متد compose آنها را ترکیب نمود و یا پارامتر سومی را جهت اعتبارسنجیهای async اضافه نمود. در این حالت شیء form تعریف شده به صورت [ngFormModel] به قالب فرم متصل میشود و از تغییرات آن آگاه خواهد شد.
ابتدا در SQL Management Studio (یا روشهای دیگر) اقدام به ایجاد رویه مینماییم.
Create Procedure dbo.PWS_GetDataCount AS BEGIN SELECT COUNT(*) FROM backupfile END
خوب تا اینجای کار هیچگونه مشکلی نیست، اما مواقعی پیش میآید که نمیخواهیم کسی به سورس این رویهها دسترسی داشته باشد، یا اینکه آنها را تغییر دهد، مثلا مواقعی که نرم افزار شما روی سایت مشتری اجرا شده و مشتری دسترسی به دیتابیس و رویههای آن دارد. در این مواقع میتوان در صورت لزوم این رویهها را فقط خواندنی کرد، یعنی حتی خود شما هم نمیتوانید رویه را ویرایش نمایید (پس دقت کنید). روال کار بدین گونه است:
Create Procedure dbo.PWS_GetDataCount2 --Parameters With Encryption AS BEGIN SELECT COUNT(*) FROM backupfile END
در واقع با نوشتن With Encryption قبل از AS میتوان رویه ذخیره شده را رمزگذاری کرد. پس از ایجاد این رویه همانگونه که در تصویر زیر مشاهده مینمایید این رویه شکل یک کلید بروی آن ظاهر شده و دیگر قابل ویرایش نیست (بهتر است رویههای خود را در زمان انتشار روی سیستم مقصد رمزگذاری نمایید).
دقت نمایید که استفاده از این عمل تاثیری در سرعت اجرای رویه ذخیره شده ندارد. البته روشهایی هم برای عکس این عمل وجود دارد که میتوانید از طریق این لینک به اطلاعات بیشتری دست پیدا کنید.
- «پیاده سازی حذف منطقی در Entity framework» حذف منطقی، یکی از الگوهای بسیار پرکاربرد در برنامههای تجاری است. توسط آن بجای حذف فیزیکی اطلاعات، آنها را تنها به عنوان رکوردی حذف شده، «علامتگذاری» میکنیم. مزایای آن نیز به شرح زیر هستند:
- داشتن سابقهی حذف اطلاعات
- جلوگیری از cascade delete
- امکان بازیابی رکوردها و امکان ایجاد قسمتی به نام recycle bin در برنامه (شبیه به recycle bin در ویندوز که امکان بازیابی موارد حذف شده را میدهد)
- امکان داشتن رکوردهایی که در یک برنامه (به ظاهر) حذف شدهاند، اما هنوز در برنامهی دیگری در حال استفاده هستند.
- بالابردن میزان امنیت برنامه. فرض کنید سایت شما هک شده و شخصی، دسترسی به پنل مدیریتی و سطوح دسترسی مدیریتی برنامه را پیدا کردهاست. در این حالت حذف تمام رکوردهای سایت توسط او، تنها به معنای تغییر یک بیت، از یک به صفر است و بازگرداندن این درجه از خسارت، تنها با روشن کردن این بیت، برطرف میشود.
پیاده سازی حذف منطقی در EF Core شامل مراحل خاصی است که در این مطلب، جزئیات آنها را بررسی خواهیم کرد.
نیاز به تعریف دو خاصیت جدید در هر جدول
هر جدولی که قرار است soft delete به آن اعمال شود، باید دارای دو فیلد جدید bool IsDeleted و DateTime? DeletedAt باشد. میتوان این خواص را به هر موجودیتی به صورت دستی اضافه کرد و یا میتوان ابتدا یک کلاس پایهی abstract را برای آن ایجاد کرد:
using System; namespace EFCoreSoftDelete.Entities { public abstract class BaseEntity { public int Id { get; set; } public bool IsDeleted { set; get; } public DateTime? DeletedAt { set; get; } } }
using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace EFCoreSoftDelete.Entities { public class Blog : BaseEntity { public string Name { set; get; } public virtual ICollection<Post> Posts { set; get; } } public class BlogConfiguration : IEntityTypeConfiguration<Blog> { public void Configure(EntityTypeBuilder<Blog> builder) { builder.Property(blog => blog.Name).HasMaxLength(450).IsRequired(); builder.HasIndex(blog => blog.Name).IsUnique(); builder.HasData(new Blog { Id = 1, Name = "Blog 1" }); builder.HasData(new Blog { Id = 2, Name = "Blog 2" }); builder.HasData(new Blog { Id = 3, Name = "Blog 3" }); } } }
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace EFCoreSoftDelete.Entities { public class Post : BaseEntity { public string Title { set; get; } public Blog Blog { set; get; } public int BlogId { set; get; } } public class PostConfiguration : IEntityTypeConfiguration<Post> { public void Configure(EntityTypeBuilder<Post> builder) { builder.Property(post => post.Title).HasMaxLength(450); builder.HasOne(post => post.Blog).WithMany(blog => blog.Posts).HasForeignKey(post => post.BlogId); builder.HasData(new Post { Id = 1, BlogId = 1, Title = "Post 1" }); builder.HasData(new Post { Id = 2, BlogId = 1, Title = "Post 2" }); builder.HasData(new Post { Id = 3, BlogId = 1, Title = "Post 3" }); builder.HasData(new Post { Id = 4, BlogId = 1, Title = "Post 4" }); builder.HasData(new Post { Id = 5, BlogId = 2, Title = "Post 5" }); } } }
حذف خودکار رکوردهایی که Soft Delete شدهاند، از نتیجهی کوئریها و گزارشات
تا اینجا فقط دو خاصیت ساده را به کلاسهای مدنظر خود اضافه کردهایم. پس از آن یا میتوان در هر جائی برای مثال شرط context.Blogs.Where(blog => !blog.IsDeleted) را به صورت دستی اعمال کرد و در گزارشات، رکوردهای حذف منطقی شده را نمایش نداد و یا از زمان ارائهی EF Core 2x میتوان برای آنها Query Filter تعریف کرد. برای مثال میتوان به تنظیمات موجودیت Blog و یا Post مراجعه نمود و با استفاده از متد HasQueryFilter، همان شرط blog => !blog.IsDeleted را به صورت سراسری به تمام کوئریهای مرتبط با این موجودیتها اعمال کرد:
public class BlogConfiguration : IEntityTypeConfiguration<Blog> { public void Configure(EntityTypeBuilder<Blog> builder) { // ... builder.HasQueryFilter(blog => !blog.IsDeleted); } }
در این حالت کوئریهای نهایی به صورت خودکار دارای شرط زیر خواهند شد:
SELECT [b].[Id], [b].[DeletedAt], [b].[IsDeleted], [b].[Name] FROM [Blogs] AS [b] WHERE [b].[IsDeleted] <> CAST(1 AS bit)
اعمال خودکار QueryFilter مخصوص Soft Delete به تمام موجودیتها
همانطور که عنوان شد، مزیت علامتگذاری موجودیتها با کلاس پایهی BaseEntity، امکان کوئری گرفتن از آنها است:
namespace EFCoreSoftDelete.DataLayer { public static class GlobalFiltersManager { public static void ApplySoftDeleteQueryFilters(this ModelBuilder modelBuilder) { foreach (var entityType in modelBuilder.Model .GetEntityTypes() .Where(eType => typeof(BaseEntity).IsAssignableFrom(eType.ClrType))) { entityType.addSoftDeleteQueryFilter(); } } private static void addSoftDeleteQueryFilter(this IMutableEntityType entityData) { var methodToCall = typeof(GlobalFiltersManager) .GetMethod(nameof(getSoftDeleteFilter), BindingFlags.NonPublic | BindingFlags.Static) .MakeGenericMethod(entityData.ClrType); var filter = methodToCall.Invoke(null, new object[] { }); entityData.SetQueryFilter((LambdaExpression)filter); } private static LambdaExpression getSoftDeleteFilter<TEntity>() where TEntity : BaseEntity { return (Expression<Func<TEntity, bool>>)(entity => !entity.IsDeleted); } } }
محل اعمال آن نیز در انتهای متد OnModelCreating است تا به صورت خودکار به تمام موجودیتهای موجود اعمال شود:
namespace EFCoreSoftDelete.DataLayer { public class ApplicationDbContext : DbContext { //... protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfigurationsFromAssembly(typeof(BaseEntity).Assembly); modelBuilder.ApplySoftDeleteQueryFilters(); }
مشکل! هنوز هم حذف فیزیکی رخ میدهد!
تنظیمات فوق، تنها بر روی کوئریهای نوشته شده تاثیر دارند؛ اما هیچگونه تاثیری را بر روی متد Remove و سپس SaveChanges نداشته و در این حالت، هنوز هم حذف واقعی و فیزیکی رخ میدهد.
برای رفع این مشکل باید به EF Core گفت، هر چند دستور حذف صادر شده، اما آنرا تبدیل به دستور Update کن؛ یعنی فیلد IsDelete را به 1 و فیلد DeletedAt را با زمان جاری مقدار دهی کن:
namespace EFCoreSoftDelete.DataLayer { public static class AuditableEntitiesManager { public static void SetAuditableEntityOnBeforeSaveChanges(this ApplicationDbContext context) { var now = DateTime.UtcNow; foreach (var entry in context.ChangeTracker.Entries<BaseEntity>()) { switch (entry.State) { case EntityState.Added: //TODO: ... break; case EntityState.Modified: //TODO: ... break; case EntityState.Deleted: entry.State = EntityState.Unchanged; //NOTE: For soft-deletes to work with the original `Remove` method. entry.Entity.IsDeleted = true; entry.Entity.DeletedAt = now; break; } } } } }
var post1 = context.Posts.Find(1); if (post1 != null) { context.Remove(post1); context.SaveChanges(); }
Executing DbCommand [Parameters=[@p2='1', @p0='2020-09-17T05:11:32' (Nullable = true), @p1='True'], CommandType='Text', CommandTimeout='30'] SET NOCOUNT ON; UPDATE [Posts] SET [DeletedAt] = @p0, [IsDeleted] = @p1 WHERE [Id] = @p2; SELECT @@ROWCOUNT;
محل اعمال متد SetAuditableEntityOnBeforeSaveChanges فوق، پیش از فراخوانی SaveChanges و به صورت زیر است:
namespace EFCoreSoftDelete.DataLayer { public class ApplicationDbContext : DbContext { // ... public override int SaveChanges(bool acceptAllChangesOnSuccess) { ChangeTracker.DetectChanges(); beforeSaveTriggers(); ChangeTracker.AutoDetectChangesEnabled = false; // for performance reasons, to avoid calling DetectChanges() again. var result = base.SaveChanges(acceptAllChangesOnSuccess); ChangeTracker.AutoDetectChangesEnabled = true; return result; } // ... private void beforeSaveTriggers() { setAuditProperties(); } private void setAuditProperties() { this.SetAuditableEntityOnBeforeSaveChanges(); } } }
حالت پیشفرض حذف رکوردها در EFCore به cascade delete تنظیم شدهاست. یعنی اگر blog با id=1 حذف شود، نه فقط این blog، بلکه تمام مطالب وابستهی به آن نیز حذف خواهند شد. اما در اینجا اگر این بلاگ را حذف کنیم:
ar blog1 = context.Blogs.FirstOrDefault(blog => blog.Id == 1); if (blog1 != null) { context.Remove(blog1); context.SaveChanges(); }
var blog1AndItsRelatedPosts = context.Blogs .Include(blog => blog.Posts) .FirstOrDefault(blog => blog.Id == 1); if (blog1AndItsRelatedPosts != null) { context.Remove(blog1AndItsRelatedPosts); context.SaveChanges(); }
SELECT [t].[Id], [t].[DeletedAt], [t].[IsDeleted], [t].[Name], [t0].[Id], [t0].[BlogId], [t0].[DeletedAt], [t0].[IsDeleted], [t0].[Title] FROM ( SELECT TOP(1) [b].[Id], [b].[DeletedAt], [b].[IsDeleted], [b].[Name] FROM [Blogs] AS [b] WHERE ([b].[IsDeleted] <> CAST(1 AS bit)) AND ([b].[Id] = 1) ) AS [t] LEFT JOIN ( SELECT [p].[Id], [p].[BlogId], [p].[DeletedAt], [p].[IsDeleted], [p].[Title] FROM [Posts] AS [p] WHERE [p].[IsDeleted] <> CAST(1 AS bit) ) AS [t0] ON [t].[Id] = [t0].[BlogId] ORDER BY [t].[Id], [t0].[Id] Executing DbCommand [Parameters=[@p2='1', @p0='2020-09-17T05:25:00' (Nullable = true), @p1='True', @p5='2', @p3='2020-09-17T05:25:00' (Nullable = true), @p4='True', @p8='3', @p6='2020-09-17T05:25:00' (Nullable = true), @p7='True', @p11='4', @p9='2020-09-17T05:25:00' (Nullable = true), @p10='True'], CommandType='Text', CommandTimeout='30'] SET NOCOUNT ON; UPDATE [Blogs] SET [DeletedAt] = @p0, [IsDeleted] = @p1 WHERE [Id] = @p2; SELECT @@ROWCOUNT; UPDATE [Posts] SET [DeletedAt] = @p3, [IsDeleted] = @p4 WHERE [Id] = @p5; SELECT @@ROWCOUNT; UPDATE [Posts] SET [DeletedAt] = @p6, [IsDeleted] = @p7 WHERE [Id] = @p8; SELECT @@ROWCOUNT; UPDATE [Posts] SET [DeletedAt] = @p9, [IsDeleted] = @p10 WHERE [Id] = @p11; SELECT @@ROWCOUNT;
یک نکته: مشکل حذف منطقی و رکوردهای منحصربفرد
فرض کنید در جدولی، فیلد نام کاربری را به عنوان یک فیلد منحصربفرد تعریف کردهاید و اکنون رکوردی در این بین، حذف منطقی شدهاست. مشکلی که در آینده بروز خواهد کرد، عدم امکان ثبت رکورد جدیدی با همان نام کاربری است که حذف منطقی شدهاست؛ چون یک unique index بر روی آن وجود دارد. در این حالت اگر از SQL Server استفاده میکنید، از قابلیتی به نام filtered indexes پشتیبانی میکند که در آن امکان تعریف یک شرط و predicate، در حین تعریف ایندکسها وجود دارد. در این حالت میتوان رکوردهای حذف منطقی شده را به ایندکس وارد نکرد.
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: EFCoreSoftDelete.zip
EF Code First #15
EF Code first و بانکهای اطلاعاتی متفاوت
در آخرین قسمت از سری EF Code first بد نیست نحوه استفاده از بانکهای اطلاعاتی دیگری را بجز SQL Server نیز بررسی کنیم. در اینجا کلاسهای مدل و کدهای مورد استفاده نیز همانند قسمت 14 است و تنها به ذکر تفاوتها و نکات مرتبط اکتفاء خواهد شد.
حالت کلی پشتیبانی از بانکهای اطلاعاتی مختلف توسط EF Code first
EF Code first با کلیه پروایدرهای تهیه شده برای ADO.NET 3.5 که پشتیبانی از EF را لحاظ کرده باشند، به خوبی کار میکند. پروایدرهای مخصوص ADO.NET 4.0، تنها سه گزینه DeleteDatabase/CreateDatabase/DatabaseExists را نسبت به نگارش قبلی بیشتر دارند و EF Code first ویژگیهای بیشتری را طلب نمیکند.
بنابراین اگر حین استفاده از پروایدر ADO.NET مخصوص بانک اطلاعاتی خاصی با پیغام «CreateDatabase is not supported by the provider» مواجه شدید، به این معنا است که این پروایدر برای دات نت 4 به روز نشده است. اما به این معنا نیست که با EF Code first کار نمیکند. فقط باید یک دیتابیس خالی از پیش تهیه شده را به برنامه معرفی کنید تا مباحث Database Migrations به خوبی کار کنند؛ یا اینکه کلا میتوانید Database Migrations را خاموش کرده (متد Database.SetInitializer را با پارامتر نال فراخوانی کنید) و فیلدها و جداول را دستی ایجاد کنید.
استفاده از EF Code first با SQLite
برای استفاده از SQLite در دات نت ابتدا نیاز به پروایدر ADO.NET آن است: «مکان دریافت درایورهای جدید SQLite مخصوص دات نت»
ضمن اینکه به نکته «استفاده از اسمبلیهای دات نت 2 در یک پروژه دات نت 4» نیز باید دقت داشت.
و یکی از بهترین management studio هایی که برای آن تهیه شده: «SQLite Manager»
پس از دریافت پروایدر آن، ارجاعی را به اسمبلی System.Data.SQLite.dll به برنامه اضافه کنید.
سپس فایل کانفیگ برنامه را به نحو زیر تغییر دهید:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<configSections>
<section name="entityFramework" type="System.Data.Entity.Internal.ConfigFile.EntityFrameworkSection, EntityFramework, Version=4.3.1.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
</configSections>
<startup useLegacyV2RuntimeActivationPolicy="true">
<supportedRuntime version="v4.0"/>
</startup>
<connectionStrings>
<clear/>
<add name="Sample09Context"
connectionString="Data Source=CodeFirst.db"
providerName="System.Data.SQLite"/>
</connectionStrings>
</configuration>
همانطور که ملاحظه میکنید، تفاوت آن با قبل، تغییر connectionString و providerName است.
اکنون اگر همان برنامه قسمت قبل را اجرا کنیم به خطای زیر برخواهیم خورد:
«The given key was not present in the dictionary»
در این مورد هم توضیح داده شد. سه گزینه DeleteDatabase/CreateDatabase/DatabaseExists در پروایدر جاری SQLite برای دات نت وجود ندارد. به همین جهت نیاز است فایل «CodeFirst.db» ذکر شده در کانکشن استرینگ را ابتدا دستی درست کرد.
برای مثال از افزونه SQLite Manager استفاده کنید. ابتدا یک بانک اطلاعاتی خالی را درست کرده و سپس دستورات زیر را بر روی بانک اطلاعاتی اجرا کنید تا دو جدول خالی را ایجاد کند (در برگه Execute sql افزونه SQLite Manager):
CREATE TABLE [Payees](
[Id] [integer] PRIMARY KEY AUTOINCREMENT NOT NULL,
[Name] [text] NULL,
[CreatedOn] [datetime] NOT NULL,
[CreatedBy] [text] NULL,
[ModifiedOn] [datetime] NOT NULL,
[ModifiedBy] [text] NULL
);
CREATE TABLE [Bills](
[Id] [integer] PRIMARY KEY AUTOINCREMENT NOT NULL,
[Amount] [float](18, 2) NOT NULL,
[Description] [text] NULL,
[CreatedOn] [datetime] NOT NULL,
[CreatedBy] [text] NULL,
[ModifiedOn] [datetime] NOT NULL,
[ModifiedBy] [text] NULL,
[Payee_Id] [integer] NULL
);
سپس سطر زیر را نیز به ابتدای برنامه اضافه کنید:
Database.SetInitializer<Sample09Context>(null);
به این ترتیب database migrations خاموش میشود و اکنون برنامه بدون مشکل کار خواهد کرد.
فقط باید به یک سری نکات مانند نوع دادهها در بانکهای اطلاعاتی مختلف دقت داشت. برای مثال integer در اینجا از نوع Int64 است؛ بنابراین در برنامه نیز باید به همین ترتیب تعریف شود تا نگاشتها به درستی انجام شوند.
در کل تنها مشکل پروایدر فعلی SQLite عدم پشتیبانی از مباحث database migrations است. این مورد را خاموش کرده و تغییرات ساختار بانک اطلاعاتی را به صورت دستی به بانک اطلاعاتی اعمال کنید. بدون مشکل کار خواهد کرد.
البته اگر به دنبال پروایدری تجاری با پشتیبانی از آخرین نگارش EF Code first هستید، گزینه زیر نیز مهیا است:
http://devart.com/dotconnect/sqlite/
برای مثال اگر علاقمند به استفاده از حالت تشکیل بانک اطلاعاتی SQLite در حافظه هستید (با رشته اتصالی ویژه Data Source=:memory:;Version=3;New=True;)، فعلا تنها گزینه مهیا استفاده از پروایدر تجاری فوق است؛ زیرا مبحث Database Migrations را به خوبی پشتیبانی میکند.
استفاده از EF Code first با SQL Server CE
قبلا در مورد «استفاده از SQL-CE به کمک NHibernate» مطلبی را در این سایت مطالعه کردهاید. سه مورد اول آن با EF Code first یکی است و تفاوتی نمیکند (یک سری بحث عمومی مشترک است). البته با یک تفاوت؛ در اینجا EF Code first قادر است یک بانک اطلاعاتی خالی SQL Server CE را به صورت خودکار ایجاد کند و نیازی نیست تا آنرا دستی ایجاد کرد. مباحث database migrations و به روز رسانی خودکار ساختار بانک اطلاعاتی نیز در اینجا پشتیبانی میشود.
برای استفاده از آن ابتدا ارجاعی را به اسمبلی System.Data.SqlServerCe.dll قرار گرفته در مسیر Program Files\Microsoft SQL Server Compact Edition\v4.0\Desktop اضافه کنید.
سپس رشته اتصالی به بانک اطلاعاتی و providerName را به نحو زیر تغییر دهید:
<connectionStrings>
<clear/>
<add name="Sample09Context"
connectionString="Data Source=mydb.sdf;Password=1234;Encrypt Database=True"
providerName="System.Data.SqlServerCE.4.0"/>
</connectionStrings>
بدون نیاز به هیچگونه تغییری در کدهای برنامه، همین مقدار تغییر در تنظیمات ابتدایی برنامه برای کار با SQL Server CE کافی است.
ضمنا مشکلی هم با فیلد Identity در آخرین نگارش EF Code first وجود ندارد؛ برخلاف حالت database first آن که پیشتر این اجازه را نمیداد و خطای «Server-generated keys and server-generated values are not supported by SQL Server Compact» را ظاهر میکرد.
استفاده از EF Code first با MySQL
برای استفاده از EF Code first با MySQL (نگارش 5 به بعد البته) ابتدا نیاز است پروایدر مخصوص ADO.NET آنرا دریافت کرد: (^)
که از EF نیز پشتیبانی میکند. پس از نصب آن، ارجاعی را به اسمبلی MySql.Data.dll قرار گرفته در مسیر Program Files\MySQL\MySQL Connector Net 6.5.4\Assemblies\v4.0 به پروژه اضافه نمائید.
سپس رشته اتصالی و providerName را به نحو زیر تغییر دهید:
<connectionStrings>
<clear/>
<add name="Sample09Context"
connectionString="Datasource=localhost; Database=testdb2; Uid=root; Pwd=123;"
providerName="MySql.Data.MySqlClient"/>
</connectionStrings>
<system.data>
<DbProviderFactories>
<remove invariant="MySql.Data.MySqlClient"/>
<add name="MySQL Data Provider"
invariant="MySql.Data.MySqlClient"
description=".Net Framework Data Provider for MySQL"
type="MySql.Data.MySqlClient.MySqlClientFactory, MySql.Data, Version=6.5.4.0, Culture=neutral, PublicKeyToken=c5687fc88969c44d" />
</DbProviderFactories>
</system.data>
همانطور که مشاهده میکنید در اینجا شماره نگارش دقیق پروایدر مورد استفاده نیز ذکر شده است. برای مثال اگر چندین پروایدر روی سیستم نصب است، با مقدار دهی DbProviderFactories میتوان از نگارش مخصوصی استفاده کرد.
با این تغییرات پس از اجرای برنامه قسمت قبل، به خطای زیر برخواهیم خورد:
The given key was not present in the dictionary
توضیحات این مورد با قسمت SQLite یکی است؛ به عبارتی نیاز است بانک اطلاعاتی testdb را دستی درست کرد. همچنین جداول و فیلدها را نیز باید دستی ایجاد کرد و database migrations را نیز باید خاموش کرد (پارامتر Database.SetInitializer را به نال مقدار دهی کنید).
برای این منظور یک دیتابیس خالی را ایجاد کرده و سپس دو جدول زیر را به آن اضافه کنید:
CREATE TABLE IF NOT EXISTS `bills` (
`Id` int(11) NOT NULL AUTO_INCREMENT,
`Amount` float DEFAULT NULL,
`Description` varchar(400) CHARACTER SET utf8 COLLATE utf8_persian_ci NOT NULL,
`CreatedOn` datetime NOT NULL,
`CreatedBy` varchar(400) CHARACTER SET utf8 COLLATE utf8_persian_ci NOT NULL,
`ModifiedOn` datetime NOT NULL,
`ModifiedBy` varchar(400) CHARACTER SET utf8 COLLATE utf8_persian_ci NOT NULL,
`Payee_Id` int(11) NOT NULL,
PRIMARY KEY (`Id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_persian_ci AUTO_INCREMENT=1 ;
CREATE TABLE IF NOT EXISTS `payees` (
`Id` int(11) NOT NULL AUTO_INCREMENT,
`Name` varchar(400) CHARACTER SET utf8 COLLATE utf8_persian_ci NOT NULL,
`CreatedOn` datetime NOT NULL,
`CreatedBy` varchar(400) CHARACTER SET utf8 COLLATE utf8_persian_ci NOT NULL,
`ModifiedOn` datetime NOT NULL,
`ModifiedBy` varchar(400) CHARACTER SET utf8 COLLATE utf8_persian_ci NOT NULL,
PRIMARY KEY (`Id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_persian_ci AUTO_INCREMENT=1 ;
پس از این تغییرات، برنامه بدون مشکل اجرا خواهد شد (ایجاد بانک اطلاعاتی خالی به همراه ایجاد ساختار جداول و خاموش کردن database migrations که توسط این پروایدر پشتیبانی نمیشود).
به علاوه پروایدر تجاری دیگری هم در سایت devart.com برای MySQL و EF Code first مهیا است که مباحث database migrations را به خوبی مدیریت میکند.
مشکل!
اگر به همین نحو برنامه را اجرا کنیم، فیلدهای یونیکد فارسی ثبت شده در MySQL با «??????? ?? ????» مقدار دهی خواهند شد و تنظیم CHARACTER SET utf8 COLLATE utf8_persian_ci نیز کافی نبوده است (این مورد با SQLite یا نگارشهای مختلف SQL Server بدون مشکل کار میکند و نیاز به تنظیم اضافهتری ندارد):
ALTER TABLE `bills` DEFAULT CHARACTER SET utf8 COLLATE utf8_persian_ci
برای رفع این مشکل توصیه شده است که CharSet=UTF8 را به رشته اتصالی به بانک اطلاعاتی اضافه کنیم. اما در این حالت خطای زیر ظاهر میشود:
The provider did not return a ProviderManifestToken string
این مورد فقط به اشتباه بودن تعاریف رشته اتصالی بر میگردد؛ یا عدم پشتیبانی از تنظیم اضافهای که در رشته اتصالی ذکر شده است.
مقدار صحیح آن دقیقا مساوی CHARSET=utf8 است (با همین نگارش و رعایت کوچکی و بزرگی حروف؛ مهم!):
<connectionStrings>
<clear/>
<add name="Sample09Context"
connectionString="Datasource=localhost; Database=testdb; Uid=root; Pwd=123;CHARSET=utf8"
providerName="MySql.Data.MySqlClient"/>
</connectionStrings>
به این ترتیب، مشکل ثبت عبارات یونیکد فارسی برطرف میشود (البته جدول هم بهتر است به DEFAULT CHARACTER SET utf8 COLLATE utf8_persian_ci تغییر پیدا کند؛ مطابق دستور Alter ایی که در بالا ذکر شد).